Files
jardin/docs/plans/2026-02-22-ameliorations-sprint.md
2026-02-22 22:18:32 +01:00

1117 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Améliorations Sprint — Jardin App
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Améliorer l'UI (settings taille/responsive), nettoyer la BDD, ajouter 404, sélection cases grille pour plantation, astuce du jour, médias galerie, export JSON, observations frontend.
**Architecture:** Frontend Vue 3 + Tailwind (CSS vars pour tailles), backend FastAPI + SQLModel + SQLite. Les settings de taille sont stockés dans `user_settings` (table clé-valeur) et appliqués via des variables CSS sur `:root` dans App.vue au chargement.
**Tech Stack:** Vue 3 TypeScript, Tailwind CSS, FastAPI, SQLModel, SQLite, Docker Compose
---
## Contexte du projet
- Backend: `/home/gilles/Documents/vscode/jardin/backend/` sur port 8060 (Docker)
- Frontend: `/home/gilles/Documents/vscode/jardin/frontend/` sur port 8061 (Docker)
- DB: `/home/gilles/Documents/vscode/jardin/data/jardin.db`
- Rebuild backend: `docker compose build backend && docker compose up -d backend` (depuis la racine du projet)
- Rebuild frontend: `docker compose build frontend && docker compose up -d frontend`
- Les `.vue` et `.ts` sont dans `frontend/src/`
- Table `user_settings` : colonnes `cle` (str, PK), `valeur` (str)
- API settings: `GET /api/settings``{cle: valeur, ...}` / `PUT /api/settings` → body `{cle: valeur, ...}`
- Thème Gruvbox Dark: bg=#282828, bg-soft=#3c3836, bg-hard=#1d2021, text=#ebdbb2, green=#b8bb26, etc.
---
## Task 1: Settings UI — Paramètres de taille d'interface
**Priorité:** Haute (demandé explicitement)
**Files:**
- Modify: `frontend/src/views/ReglagesView.vue`
- Modify: `frontend/src/App.vue`
**Paramètres à ajouter (clés dans user_settings):**
- `ui_font_size` : taille texte général (1220px, défaut 14)
- `ui_menu_font_size` : taille texte menu latéral (1118px, défaut 13)
- `ui_menu_icon_size` : taille icônes menu (1428px, défaut 18)
- `ui_thumb_size` : taille miniatures images/vidéos (60200px, défaut 96)
**Step 1: Modifier ReglagesView.vue — ajouter section Interface**
Ajouter une nouvelle section avant la section "Général" dans `ReglagesView.vue` :
```vue
<section class="bg-bg-soft border border-bg-hard rounded-xl p-4 mb-4">
<h2 class="text-text font-semibold mb-2">Interface</h2>
<p class="text-text-muted text-sm mb-4">Ajustez les tailles d'affichage. Les changements sont appliqués instantanément.</p>
<div class="grid grid-cols-1 gap-4">
<div v-for="s in uiSizeSettings" :key="s.key" class="flex items-center gap-3">
<label class="text-sm text-text w-44 shrink-0">{{ s.label }}</label>
<input
type="range"
:min="s.min" :max="s.max" :step="s.step"
v-model.number="uiSizes[s.key]"
class="flex-1 accent-green"
@input="applyUiSizes"
/>
<span class="text-text-muted text-xs w-12 text-right">{{ uiSizes[s.key] }}{{ s.unit }}</span>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
:disabled="savingUi"
@click="saveUiSettings"
>{{ savingUi ? 'Enregistrement...' : 'Enregistrer' }}</button>
<button
class="text-text-muted text-xs hover:text-text px-2"
@click="resetUiSettings"
>Réinitialiser</button>
<span v-if="uiSavedMsg" class="text-xs text-aqua">{{ uiSavedMsg }}</span>
</div>
</section>
```
Dans `<script setup lang="ts">`, ajouter :
```typescript
// --- UI Size settings ---
const UI_DEFAULTS = { ui_font_size: 14, ui_menu_font_size: 13, ui_menu_icon_size: 18, ui_thumb_size: 96 }
const uiSizeSettings = [
{ key: 'ui_font_size', label: 'Taille texte', min: 12, max: 20, step: 1, unit: 'px' },
{ key: 'ui_menu_font_size', label: 'Texte menu latéral', min: 11, max: 18, step: 1, unit: 'px' },
{ key: 'ui_menu_icon_size', label: 'Icônes menu', min: 14, max: 28, step: 1, unit: 'px' },
{ key: 'ui_thumb_size', label: 'Miniatures images/vidéo', min: 60, max: 200, step: 4, unit: 'px' },
]
const uiSizes = ref<Record<string, number>>({ ...UI_DEFAULTS })
const savingUi = ref(false)
const uiSavedMsg = ref('')
function applyUiSizes() {
const root = document.documentElement
root.style.setProperty('--ui-font-size', `${uiSizes.value.ui_font_size}px`)
root.style.setProperty('--ui-menu-font-size', `${uiSizes.value.ui_menu_font_size}px`)
root.style.setProperty('--ui-menu-icon-size', `${uiSizes.value.ui_menu_icon_size}px`)
root.style.setProperty('--ui-thumb-size', `${uiSizes.value.ui_thumb_size}px`)
window.dispatchEvent(new CustomEvent('ui-sizes-updated', { detail: { ...uiSizes.value } }))
}
async function saveUiSettings() {
savingUi.value = true
uiSavedMsg.value = ''
try {
const payload: Record<string, string> = {}
for (const [k, v] of Object.entries(uiSizes.value)) payload[k] = String(v)
await settingsApi.update(payload)
applyUiSizes()
uiSavedMsg.value = 'Enregistré'
setTimeout(() => { uiSavedMsg.value = '' }, 1800)
} finally {
savingUi.value = false
}
}
function resetUiSettings() {
uiSizes.value = { ...UI_DEFAULTS }
applyUiSizes()
}
```
Dans `loadSettings()`, ajouter après `debugMode.value = ...` :
```typescript
for (const s of uiSizeSettings) {
const v = data[s.key]
if (v != null) uiSizes.value[s.key] = Number(v) || UI_DEFAULTS[s.key as keyof typeof UI_DEFAULTS]
}
applyUiSizes()
```
**Step 2: App.vue — appliquer les CSS vars au chargement**
Dans `App.vue`, modifier `loadDebugModeFromApi()` pour aussi charger et appliquer les tailles UI. Ajouter une fonction `applyUiSizesFromSettings(data: Record<string, string>)`:
```typescript
function applyUiSizesFromSettings(data: Record<string, string>) {
const defaults = { ui_font_size: 14, ui_menu_font_size: 13, ui_menu_icon_size: 18, ui_thumb_size: 96 }
const root = document.documentElement
for (const [key, def] of Object.entries(defaults)) {
const val = Number(data[key]) || def
const prop = '--' + key.replace(/_/g, '-')
root.style.setProperty(prop, `${val}px`)
}
}
```
Appeler dans `loadDebugModeFromApi()` après avoir récupéré `data`.
**Step 3: Utiliser les CSS vars dans App.vue sidebar**
Modifier les classes du sidebar pour utiliser les vars via `style` inline :
```vue
<!-- Icône nav: -->
<span :style="`font-size: var(--ui-menu-icon-size, 18px)`" class="leading-none">{{ l.icon }}</span>
<!-- Label nav: -->
<span :style="`font-size: var(--ui-menu-font-size, 13px)`">{{ l.label }}</span>
```
Dans le `<main>` racine, ajouter `style="font-size: var(--ui-font-size, 14px)"`.
**Step 4: Appliquer thumb_size aux images existantes**
Dans les vues qui affichent des thumbnails (MediaGallery, BibliothequeView, etc.), utiliser `style="width: var(--ui-thumb-size, 96px); height: var(--ui-thumb-size, 96px)"` pour les conteneurs d'images.
**Step 5: Rebuild frontend et vérifier**
```bash
cd /home/gilles/Documents/vscode/jardin
docker compose build frontend && docker compose up -d frontend
```
Aller sur http://10.0.1.109:8061/reglages → vérifier que les 4 sliders sont présents → déplacer un slider → texte change instantanément.
**Step 6: Commit**
```bash
git add frontend/src/views/ReglagesView.vue frontend/src/App.vue
git commit -m "feat(settings): sliders taille texte, menu, icônes, miniatures + CSS vars"
```
---
## Task 2: Layout — Optimisation largeur desktop + responsive mobile
**Priorité:** Haute (demandé explicitement)
**Problème actuel:** Les vues ont `max-w-3xl` (48rem) codé en dur, ce qui est trop étroit sur laptop (1366px+). Le mobile a des overflow sur certaines vues.
**Files:**
- Modify: `frontend/src/App.vue` (main layout)
- Modify: toutes les vues: `DashboardView.vue`, `JardinsView.vue`, `JardinDetailView.vue`, `PlantesView.vue`, `PlantationsView.vue`, `TachesView.vue`, `PlanningView.vue`, `ReglagesView.vue`, `OutilsView.vue`, `AstucesView.vue`
**Step 1: App.vue — augmenter le max-w du content**
Dans `<main>`, changer la classe pour permettre plus de largeur sur desktop. Actuellement pas de max-w sur main, mais les vues individuelles ont max-w-3xl.
Modifier `<main>` dans App.vue :
```vue
<main class="pt-14 lg:pt-0 lg:pl-60 min-h-screen w-full bg-bg">
<div class="mx-auto px-2 sm:px-4 max-w-[1400px]">
<RouterView />
</div>
</main>
```
**Step 2: Standardiser les vues — remplacer max-w-3xl par max-w-5xl**
Dans chaque vue, remplacer `class="p-4 max-w-3xl mx-auto"` par `class="p-4 max-w-5xl mx-auto"`.
Vues concernées:
- `DashboardView.vue` ligne 2
- `JardinsView.vue`
- `JardinDetailView.vue` ligne 2
- `PlantesView.vue`
- `PlantationsView.vue`
- `TachesView.vue`
- `PlanningView.vue` ligne 2 (`max-w-3xl``max-w-5xl`)
- `ReglagesView.vue` ligne 2 (`max-w-3xl``max-w-5xl`)
- `OutilsView.vue`
- `AstucesView.vue`
- `CalendrierView.vue` (si max-w-3xl présent)
**Step 3: Vérifier mobile — s'assurer que les tables/grilles ont overflow-x-auto**
Dans `JardinDetailView.vue`, la grille a déjà `overflow-x-auto`. Vérifier les autres vues.
Dans `PlanningView.vue`, le calendrier `grid grid-cols-7` est OK.
**Step 4: Dashboard — layout 2 colonnes sur desktop**
Dans `DashboardView.vue`, envelopper les sections Tâches+Jardins dans une colonne et Météo dans l'autre:
```vue
<div class="lg:grid lg:grid-cols-3 lg:gap-6">
<div class="lg:col-span-2">
<!-- section tâches -->
<!-- section jardins -->
</div>
<div>
<!-- section météo -->
</div>
</div>
```
**Step 5: Rebuild et vérifier**
```bash
docker compose build frontend && docker compose up -d frontend
```
Sur laptop: vérifier que le contenu utilise plus de largeur.
Sur mobile: vérifier que pas d'overflow horizontal.
**Step 6: Commit**
```bash
git add frontend/src/App.vue frontend/src/views/DashboardView.vue frontend/src/views/JardinsView.vue frontend/src/views/JardinDetailView.vue frontend/src/views/PlantesView.vue frontend/src/views/PlantationsView.vue frontend/src/views/TachesView.vue frontend/src/views/PlanningView.vue frontend/src/views/ReglagesView.vue frontend/src/views/OutilsView.vue frontend/src/views/AstucesView.vue
git commit -m "feat(layout): max-w-5xl + 2 colonnes dashboard desktop + overflow mobile"
```
---
## Task 3: B1 — Nettoyer données orphelines BDD (priorité haute)
**Contexte:** Garden_id=1 n'existe plus. 24 `garden_cells`, 1 `planting`, 1 `measurement` sont orphelins.
**Files:**
- Modify: `backend/app/migrate.py` (ajouter étape de nettoyage)
**Step 1: Vérifier l'état en BDD**
```bash
sqlite3 /home/gilles/Documents/vscode/jardin/data/jardin.db "
SELECT 'garden_cells orphelins' as t, count(*) FROM garden_cell WHERE garden_id NOT IN (SELECT id FROM garden);
SELECT 'plantings orphelins' as t, count(*) FROM planting WHERE garden_id NOT IN (SELECT id FROM garden);
SELECT 'measurements orphelins' as t, count(*) FROM measurement WHERE garden_id NOT IN (SELECT id FROM garden);
"
```
**Step 2: Nettoyage SQL direct**
```bash
sqlite3 /home/gilles/Documents/vscode/jardin/data/jardin.db "
DELETE FROM garden_cell WHERE garden_id NOT IN (SELECT id FROM garden);
DELETE FROM planting WHERE garden_id NOT IN (SELECT id FROM garden);
DELETE FROM measurement WHERE garden_id NOT IN (SELECT id FROM garden);
SELECT changes() || ' lignes supprimées';
"
```
**Step 3: Ajouter le nettoyage dans migrate.py**
Dans `backend/app/migrate.py`, ajouter une fonction `cleanup_orphans(conn)` appelée à la fin de la migration :
```python
def cleanup_orphans(conn):
"""Supprime les enregistrements orphelins (FK rompues après suppression d'entités parentes)."""
cursor = conn.cursor()
orphan_queries = [
("garden_cell", "garden_id", "garden"),
("planting", "garden_id", "garden"),
("measurement", "garden_id", "garden"),
]
for table, fk_col, parent_table in orphan_queries:
try:
cursor.execute(f"DELETE FROM {table} WHERE {fk_col} NOT IN (SELECT id FROM {parent_table})")
if cursor.rowcount:
print(f" [cleanup] {cursor.rowcount} orphans supprimés de {table}")
except Exception as e:
print(f" [cleanup] Erreur sur {table}: {e}")
conn.commit()
```
**Step 4: Commit**
```bash
git add backend/app/migrate.py
git commit -m "fix(db): nettoyage orphelins garden_cells/plantings/measurements + migrate.py"
```
---
## Task 4: B4 — Page 404 frontend
**Priorité:** Haute
**Files:**
- Create: `frontend/src/views/NotFoundView.vue`
- Modify: `frontend/src/router/index.ts`
**Step 1: Créer NotFoundView.vue**
```vue
<template>
<div class="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center">
<div class="text-8xl mb-6">🌿</div>
<h1 class="text-4xl font-bold text-green mb-2">404</h1>
<p class="text-text-muted text-lg mb-6">Cette page n'existe pas dans le jardin.</p>
<RouterLink
to="/"
class="bg-green text-bg px-6 py-3 rounded-lg font-semibold hover:opacity-90 transition-opacity"
>
Retour au tableau de bord
</RouterLink>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
```
**Step 2: Ajouter route catch-all dans router/index.ts**
Ajouter en dernière position dans le tableau `routes` :
```typescript
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue') },
```
**Step 3: Rebuild et tester**
```bash
docker compose build frontend && docker compose up -d frontend
```
Aller sur http://10.0.1.109:8061/page-inexistante → vérifier 404 page.
**Step 4: Commit**
```bash
git add frontend/src/views/NotFoundView.vue frontend/src/router/index.ts
git commit -m "feat(frontend): page 404 avec redirect vers dashboard"
```
---
## Task 5: B3 — Populer la table lunarcalendarentry
**Priorité:** Haute
**Contexte:** La table `lunarcalendarentry` existe (0 lignes). Le service lunar est dans `backend/app/services/lunar.py` et retourne les données par mois via `/api/lunar?month=YYYY-MM`.
**Files:**
- Read first: `backend/app/routers/lunar.py`
- Read first: `backend/app/models/settings.py` (pour voir LunarCalendarEntry)
- Modify: `backend/app/routers/lunar.py` (auto-persist after compute)
**Step 1: Lire les fichiers concernés**
```bash
cat /home/gilles/Documents/vscode/jardin/backend/app/routers/lunar.py
cat /home/gilles/Documents/vscode/jardin/backend/app/services/lunar.py | head -80
```
**Step 2: Vérifier le modèle LunarCalendarEntry**
```bash
cat /home/gilles/Documents/vscode/jardin/backend/app/models/settings.py
```
**Step 3: Modifier le router lunar pour persister les données**
Dans `backend/app/routers/lunar.py`, après avoir calculé les données du mois, sauvegarder chaque jour dans `lunarcalendarentry`. Pattern upsert :
```python
from app.models.settings import LunarCalendarEntry
from sqlmodel import Session, select
from app.database import get_session
# Dans l'endpoint GET /api/lunar?month=YYYY-MM :
# Après avoir calculé days_data, persister :
for day_data in days_data:
existing = session.exec(
select(LunarCalendarEntry).where(LunarCalendarEntry.date == day_data['date'])
).first()
if not existing:
entry = LunarCalendarEntry(
date=day_data['date'],
phase=day_data.get('phase'),
illumination=day_data.get('illumination'),
type_jour=day_data.get('type_jour'),
# ... autres champs selon le modèle
)
session.add(entry)
session.commit()
```
**Step 4: Pré-populer les 3 prochains mois au démarrage**
Dans `backend/app/main.py`, dans le lifespan, après la migration, appeler l'endpoint lunar pour les 3 mois autour de la date courante pour pré-remplir la table.
**Step 5: Vérifier**
```bash
sqlite3 /home/gilles/Documents/vscode/jardin/data/jardin.db "SELECT count(*) FROM lunarcalendarentry;"
# Doit être > 0 après restart
```
**Step 6: Commit**
```bash
git commit -m "feat(lunar): persister les données calculées dans lunarcalendarentry"
```
---
## Task 6: Grille jardin — Sélection de case pour ajouter une plantation
**Priorité:** Haute (demandé explicitement : "je peux sélectionner des cases de la grille jardin pour ajouter une plantation")
**Files:**
- Modify: `frontend/src/views/JardinDetailView.vue`
- Read first: `frontend/src/api/plants.ts` et `frontend/src/api/gardens.ts` pour les types
**Ce que ça fait:**
- Clic sur une case libre → modal s'ouvre avec formulaire de création de plantation
- Le formulaire propose: plante (liste déroulante), date plantation, notes
- POST vers /api/plantings avec `garden_id`, `plant_id`, `cell_row`, `cell_col`
- Après succès: la case passe en "occupé" (orange)
**Step 1: Lire les fichiers API nécessaires**
```bash
cat /home/gilles/Documents/vscode/jardin/frontend/src/api/plants.ts | head -40
cat /home/gilles/Documents/vscode/jardin/frontend/src/api/gardens.ts | head -40
```
**Step 2: Ajouter le modal de plantation dans JardinDetailView.vue**
Ajouter dans le `<template>` après la grille :
```vue
<!-- Modal ajout plantation sur case -->
<Teleport to="body">
<div v-if="selectedCell" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="selectedCell = null">
<div class="bg-bg-hard border border-bg-soft rounded-xl p-6 w-full max-w-sm mx-4">
<h3 class="text-text font-semibold mb-4">
Planter en case {{ selectedCell.libelle }}
</h3>
<div class="space-y-3">
<div>
<label class="text-text-muted text-xs mb-1 block">Plante *</label>
<select v-model="form.plant_id" class="w-full bg-bg border border-bg-soft rounded px-3 py-2 text-text text-sm">
<option :value="null" disabled>-- Choisir une plante --</option>
<option v-for="p in plants" :key="p.id" :value="p.id">{{ p.nom }}</option>
</select>
</div>
<div>
<label class="text-text-muted text-xs mb-1 block">Date de plantation</label>
<input type="date" v-model="form.date_plantation"
class="w-full bg-bg border border-bg-soft rounded px-3 py-2 text-text text-sm" />
</div>
<div>
<label class="text-text-muted text-xs mb-1 block">Notes</label>
<input type="text" v-model="form.notes" placeholder="Optionnel"
class="w-full bg-bg border border-bg-soft rounded px-3 py-2 text-text text-sm" />
</div>
</div>
<div class="flex gap-2 mt-5">
<button
class="flex-1 bg-green text-bg py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50"
:disabled="!form.plant_id || savingPlantation"
@click="savePlantation"
>{{ savingPlantation ? 'Enregistrement...' : 'Planter' }}</button>
<button class="px-4 py-2 text-text-muted text-sm hover:text-text" @click="selectedCell = null">Annuler</button>
</div>
</div>
</div>
</Teleport>
```
**Step 3: Modifier les cases de la grille pour être cliquables**
Sur chaque case `<div v-for="cell in displayCells"`, ajouter `@click="openCellModal(cell)"`.
Changer la classe pour mieux indiquer la cliquabilité.
**Step 4: Script setup — ajouter la logique**
```typescript
import { plantsApi, type Plant } from '@/api/plants'
import { plantingsApi } from '@/api/plantings'
const plants = ref<Plant[]>([])
const selectedCell = ref<GardenCell | null>(null)
const savingPlantation = ref(false)
const form = ref({ plant_id: null as number | null, date_plantation: '', notes: '' })
function openCellModal(cell: GardenCell) {
selectedCell.value = cell
form.value = { plant_id: null, date_plantation: new Date().toISOString().slice(0, 10), notes: '' }
}
async function savePlantation() {
if (!selectedCell.value || !form.value.plant_id || !garden.value) return
savingPlantation.value = true
try {
await plantingsApi.create({
garden_id: garden.value.id!,
plant_id: form.value.plant_id,
cell_row: selectedCell.value.row,
cell_col: selectedCell.value.col,
date_plantation: form.value.date_plantation || undefined,
notes: form.value.notes || undefined,
})
// Rafraîchir les cellules
cells.value = await gardensApi.cells(garden.value.id!)
selectedCell.value = null
} catch (e) {
alert('Erreur lors de la création de la plantation.')
} finally {
savingPlantation.value = false
}
}
// Dans onMounted(), ajouter:
// plants.value = await plantsApi.getAll()
```
**Step 5: Vérifier l'API plantings**
```bash
cat /home/gilles/Documents/vscode/jardin/frontend/src/api/plantings.ts 2>/dev/null | head -40
```
Si `plantingsApi.create` n'existe pas, l'adapter selon l'API existante.
**Step 6: Rebuild et tester**
```bash
docker compose build frontend && docker compose up -d frontend
```
Sur http://10.0.1.109:8061/jardins/2, cliquer une case → vérifier que le modal s'ouvre.
**Step 7: Commit**
```bash
git add frontend/src/views/JardinDetailView.vue
git commit -m "feat(grille): sélection de case pour créer une plantation (modal inline)"
```
---
## Task 7: F3 — Astuce du jour sur le Dashboard
**Priorité:** Moyenne
**Files:**
- Modify: `frontend/src/views/DashboardView.vue`
- Read first: `frontend/src/api/astuces.ts` ou `frontend/src/stores/astuces.ts`
**Ce que ça fait:**
- Afficher une "Astuce du jour" dans le Dashboard
- Sélection par `jour_annee = dayOfYear(today) % nb_astuces` (déterministe, change chaque jour)
**Step 1: Lire l'API astuces**
```bash
cat /home/gilles/Documents/vscode/jardin/frontend/src/api/astuces.ts 2>/dev/null || ls frontend/src/api/
```
**Step 2: Ajouter la section "Astuce du jour" dans DashboardView.vue**
Dans le `<template>`, ajouter une section après "Tâches à faire" :
```vue
<section class="mb-6" v-if="astuceJour">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Astuce du jour</h2>
<div class="bg-bg-soft rounded-xl p-4 border border-bg-hard flex gap-3 items-start">
<span class="text-2xl">💡</span>
<div>
<div class="text-text text-sm font-medium mb-1">{{ astuceJour.titre }}</div>
<div class="text-text-muted text-xs leading-relaxed">{{ astuceJour.contenu }}</div>
<div v-if="astuceJour.source" class="text-text-muted text-xs mt-1 opacity-60"> {{ astuceJour.source }}</div>
</div>
</div>
</section>
```
**Step 3: Script — logique astuce du jour**
```typescript
import { astucesApi } from '@/api/astuces' // adapter selon le nom exact
const astuceJour = ref<{titre: string; contenu: string; source?: string} | null>(null)
function dayOfYear(): number {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 0)
return Math.floor((now.getTime() - start.getTime()) / 86400000)
}
// Dans onMounted():
try {
const all = await astucesApi.getAll()
if (all.length) astuceJour.value = all[dayOfYear() % all.length]
} catch { astuceJour.value = null }
```
**Step 4: Commit**
```bash
git add frontend/src/views/DashboardView.vue
git commit -m "feat(dashboard): astuce du jour (rotation quotidienne déterministe)"
```
---
## Task 8: F2 — Améliorer l'affichage des saints du jour dans CalendrierView
**Priorité:** Moyenne
**Contexte:** La table `saint_du_jour` est maintenant remplie (366 jours). L'afficher dans CalendrierView.
**Files:**
- Modify: `frontend/src/views/CalendrierView.vue`
- Create: `frontend/src/api/saints.ts`
**Step 1: Créer l'API saints**
```typescript
// frontend/src/api/saints.ts
import client from './client'
export interface SaintJour {
mois: number
jour: number
saints: string[]
source_url?: string
}
export const saintsApi = {
getJour: (mois: number, jour: number) =>
client.get<SaintJour>('/api/saints/jour', { params: { mois, jour } }).then(r => r.data),
}
```
**Step 2: Dans CalendrierView.vue — afficher le saint du jour**
Repérer où le jour courant est affiché (dans le panneau de détail). Ajouter :
```vue
<div v-if="saintJour?.saints?.length" class="text-text-muted text-xs mt-2">
<span class="text-yellow mr-1"></span>{{ saintJour.saints[0] }}
<span v-if="saintJour.saints.length > 1" class="opacity-60"> (+{{ saintJour.saints.length - 1 }})</span>
</div>
```
**Step 3: Charger le saint pour le jour sélectionné**
```typescript
import { saintsApi, type SaintJour } from '@/api/saints'
const saintJour = ref<SaintJour | null>(null)
// Quand le jour change (watch selectedDate ou onMounted):
watch(selectedDate, async (d) => {
if (!d) return
const date = new Date(d)
try {
saintJour.value = await saintsApi.getJour(date.getMonth() + 1, date.getDate())
} catch { saintJour.value = null }
})
```
**Step 4: Commit**
```bash
git add frontend/src/api/saints.ts frontend/src/views/CalendrierView.vue
git commit -m "feat(calendrier): afficher le saint du jour via /api/saints/jour"
```
---
## Task 9: B2 — Nettoyage fichiers upload test
**Priorité:** Haute (disque à 80%)
**Ce que ça fait:** Identifier les fichiers dans `/data/uploads/` qui ne sont pas référencés dans la table `media` et les supprimer.
**Step 1: Lister les fichiers non référencés**
```bash
sqlite3 /home/gilles/Documents/vscode/jardin/data/jardin.db "SELECT url FROM media;" > /tmp/media_urls.txt
ls /home/gilles/Documents/vscode/jardin/data/uploads/ 2>/dev/null | head -20
```
**Step 2: Comparer et lister les orphelins**
Script Python pour identifier les fichiers non référencés :
```python
import sqlite3, os
from pathlib import Path
db = Path('/home/gilles/Documents/vscode/jardin/data/jardin.db')
uploads = Path('/home/gilles/Documents/vscode/jardin/data/uploads')
conn = sqlite3.connect(str(db))
referenced = set()
for (url,) in conn.execute("SELECT url FROM media WHERE url IS NOT NULL"):
fname = url.split('/')[-1] if url else ''
referenced.add(fname)
for (url,) in conn.execute("SELECT thumbnail_url FROM media WHERE thumbnail_url IS NOT NULL"):
fname = url.split('/')[-1] if url else ''
referenced.add(fname)
conn.close()
orphans = []
total_size = 0
for f in uploads.rglob('*'):
if f.is_file() and f.name not in referenced:
orphans.append(f)
total_size += f.stat().st_size
print(f"{len(orphans)} fichiers orphelins, {total_size/1024/1024:.1f} MB")
for f in orphans[:20]:
print(f" {f}")
```
**Step 3: Supprimer les orphelins (après confirmation manuelle)**
```bash
# Lancer le script d'abord en dry-run, puis supprimer
# NE PAS automatiser la suppression sans vérification manuelle
```
**Step 4: Commit (message uniquement)**
```bash
git commit --allow-empty -m "chore: nettoyage manuel fichiers uploads non référencés"
```
---
## Task 10: B5 — Export JSON
**Priorité:** Moyenne (plan 10.2)
**Files:**
- Modify: `backend/app/routers/settings.py` (ajouter endpoint export)
**Step 1: Ajouter endpoint GET /api/export**
Dans `backend/app/routers/settings.py`, ajouter :
```python
from fastapi.responses import JSONResponse
from app.models import Garden, Plant, Planting, Task, Tool, Astuce, Dicton
from sqlmodel import select
@router.get("/export")
def export_all_data(session: Session = Depends(get_session)):
"""Export complet de toutes les données en JSON."""
def rows(model):
return [r.model_dump() for r in session.exec(select(model)).all()]
return JSONResponse(content={
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"gardens": rows(Garden),
"plants": rows(Plant),
"plantings": rows(Planting),
"tasks": rows(Task),
"tools": rows(Tool),
"astuces": rows(Astuce),
"dictons": rows(Dicton),
})
```
**Step 2: Ajouter bouton dans ReglagesView.vue**
Dans la section "Sauvegarde des données", ajouter un bouton "Exporter JSON":
```vue
<button @click="exportJson" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
Exporter JSON
</button>
```
```typescript
async function exportJson() {
const r = await fetch('/api/export')
const blob = await r.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `jardin_export_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
```
**Step 3: Rebuild et tester**
```bash
docker compose build backend && docker compose up -d backend
```
`curl http://localhost:8060/api/export | python3 -m json.tool | head -20`
**Step 4: Commit**
```bash
git commit -m "feat(backend): endpoint GET /api/export JSON complet + bouton frontend"
```
---
## Task 11: B6 — Section Observations dans la fiche plantation
**Priorité:** Moyenne (plan 5.2 frontend)
**Contexte:** Le backend `Observation` existe avec router `/api/observations`. À afficher dans PlantationsView.
**Files:**
- Read first: `backend/app/routers/observations.py` pour comprendre l'API
- Modify: `frontend/src/views/PlantationsView.vue`
- Create: `frontend/src/api/observations.ts`
**Step 1: Lire le router observations**
```bash
cat /home/gilles/Documents/vscode/jardin/backend/app/routers/observations.py
```
**Step 2: Créer frontend/src/api/observations.ts**
```typescript
import client from './client'
export interface Observation {
id?: number
planting_id?: number
garden_id?: number
type: 'maladie' | 'ravageur' | 'traitement' | 'note'
titre: string
description?: string
date: string
photo_url?: string
}
export const observationsApi = {
getByPlanting: (plantingId: number) =>
client.get<Observation[]>('/api/observations', { params: { planting_id: plantingId } }).then(r => r.data),
create: (obs: Omit<Observation, 'id'>) =>
client.post<Observation>('/api/observations', obs).then(r => r.data),
delete: (id: number) => client.delete(`/api/observations/${id}`).then(r => r.data),
}
```
**Step 3: Ajouter section Observations dans PlantationsView.vue**
Dans la fiche dépliable de chaque plantation, ajouter un sous-section observations. S'inspirer du pattern récoltes déjà implémenté.
**Step 4: Commit**
```bash
git commit -m "feat(plantations): section observations (maladies/ravageurs/traitements)"
```
---
## Task 12: B7 — Composant MediaGallery.vue réutilisable
**Priorité:** Moyenne (plan 2.2 frontend)
**Files:**
- Create: `frontend/src/components/MediaGallery.vue`
- Modify: `frontend/src/views/JardinDetailView.vue` (utiliser le composant)
- Read first: `frontend/src/api/media.ts` ou similaire
**Step 1: Créer MediaGallery.vue**
```vue
<!-- Props: entityType: string, entityId: number -->
<template>
<div>
<div v-if="medias.length" class="grid gap-2"
:style="`grid-template-columns: repeat(auto-fill, minmax(var(--ui-thumb-size, 96px), 1fr))`">
<div v-for="m in medias" :key="m.id" class="relative group">
<img
:src="m.thumbnail_url || m.url"
:alt="m.titre || 'Photo'"
class="rounded-lg object-cover border border-bg-hard cursor-pointer hover:border-green transition-colors"
:style="`width: var(--ui-thumb-size, 96px); height: var(--ui-thumb-size, 96px)`"
@click="lightbox = m"
/>
<button
class="absolute top-1 right-1 bg-bg-hard/80 text-red text-xs rounded px-1 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="deleteMedia(m.id!)"
></button>
</div>
</div>
<div v-else class="text-text-muted text-xs py-2">Aucune photo.</div>
<!-- Upload -->
<label class="mt-2 inline-flex items-center gap-1 text-xs text-blue hover:text-text cursor-pointer">
<input type="file" accept="image/*,video/*" class="hidden" @change="uploadFile" multiple />
+ Ajouter des photos
</label>
<!-- Lightbox -->
<Teleport to="body">
<div v-if="lightbox" class="fixed inset-0 z-50 bg-black/85 flex items-center justify-center" @click.self="lightbox = null">
<div class="max-w-4xl max-h-screen p-4">
<img :src="lightbox.url" :alt="lightbox.titre || 'Photo'" class="max-w-full max-h-[80vh] rounded-lg" />
<div class="text-center text-text-muted text-sm mt-2">{{ lightbox.titre }}</div>
<button class="absolute top-4 right-4 text-text text-2xl hover:text-red" @click="lightbox = null"></button>
</div>
</div>
</Teleport>
</div>
</template>
```
**Step 2: Intégrer dans JardinDetailView.vue**
```vue
<section class="mt-6">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Photos</h2>
<MediaGallery entity-type="jardin" :entity-id="garden.id!" />
</section>
```
**Step 3: Commit**
```bash
git commit -m "feat(frontend): composant MediaGallery.vue réutilisable avec lightbox + upload"
```
---
## Task 13: Dashboard — Dicton du jour + amélioration
**Priorité:** Moyenne (liée aux items dictons)
**Files:**
- Modify: `frontend/src/views/DashboardView.vue`
**Ce que ça fait:** Afficher le dicton du jour correspondant à la date courante.
**Step 1: Ajouter la logique dicton dans DashboardView.vue**
```typescript
import { dictonsApi } from '@/api/dictons' // vérifier le nom exact
const dictonJour = ref<{texte: string} | null>(null)
// Dans onMounted():
try {
const today = new Date()
const mois = today.getMonth() + 1
const jour = today.getDate()
const dictons = await dictonsApi.getByMois(mois)
// Chercher d'abord le dicton du jour exact, sinon aléatoire du mois
const exact = dictons.find((d: any) => d.jour === jour)
dictonJour.value = exact || (dictons.length ? dictons[Math.floor(Math.random() * dictons.length)] : null)
} catch { dictonJour.value = null }
```
**Step 2: Afficher dans le template**
```vue
<section class="mb-6" v-if="dictonJour">
<h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Dicton du jour</h2>
<blockquote class="bg-bg-soft rounded-xl p-4 border border-bg-hard border-l-4 border-l-yellow">
<p class="text-text text-sm italic">« {{ dictonJour.texte }} »</p>
</blockquote>
</section>
```
**Step 3: Commit**
```bash
git add frontend/src/views/DashboardView.vue
git commit -m "feat(dashboard): dicton du jour depuis la BDD (jour exact ou du mois)"
```
---
## Task 14: Renommer /meteo en /calendrier dans le header
**Priorité:** Basse (item 17 — route calendrier)
**Contexte:** La route `/calendrier` redirige vers `/meteo`. Mais dans le header/drawer, le lien est labelled "Météo". L'utilisateur veut "Calendrier" dans la nav.
**Files:**
- Modify: `frontend/src/App.vue` (sidebar link)
- Modify: `frontend/src/components/AppDrawer.vue`
**Step 1: Dans App.vue, renommer le lien**
```typescript
// Changer:
{ to: '/meteo', label: 'Météo', icon: '🌦️' },
// En:
{ to: '/meteo', label: 'Calendrier', icon: '📅' },
```
**Step 2: Dans AppDrawer.vue, renommer**
```typescript
// Changer:
{ to: '/meteo', label: 'Météo' },
// En:
{ to: '/meteo', label: 'Calendrier' },
```
**Step 3: Commit**
```bash
git commit -m "chore(nav): renommer 'Météo' → 'Calendrier' dans la navigation"
```
---
## Task 15: Amélioration CalendrierView — Titre + route
**Priorité:** Basse (item 19-20)
**Contexte:** Le titre de CalendrierView.vue est "🌦️ Météo". Renommer en "📅 Calendrier". Ajouter `/calendrier` comme route principale (et garder `/meteo` en redirect).
**Files:**
- Modify: `frontend/src/views/CalendrierView.vue` (h1)
- Modify: `frontend/src/router/index.ts` (route principale /calendrier)
**Step 1: Modifier le titre dans CalendrierView.vue**
Changer `<h1>🌦️ Météo</h1>` (ou similaire) en `<h1>📅 Calendrier</h1>`.
**Step 2: Modifier le router**
```typescript
// Remplacer:
{ path: '/meteo', component: () => import('@/views/CalendrierView.vue') },
{ path: '/calendrier', redirect: '/meteo' },
// Par:
{ path: '/calendrier', component: () => import('@/views/CalendrierView.vue') },
{ path: '/meteo', redirect: '/calendrier' },
```
Mettre à jour les liens dans App.vue et AppDrawer.vue : `to="/calendrier"`.
**Step 3: Commit**
```bash
git commit -m "refactor(nav): /calendrier route principale, /meteo redirect + titre CalendrierView"
```
---
## Ordre d'exécution recommandé
1. Task 1 (Settings sliders) ← demandé explicitement, impact immédiat
2. Task 2 (Layout) ← demandé explicitement
3. Task 4 (404 page) ← simple, haute priorité
4. Task 6 (Grille jardin + plantation) ← demandé explicitement
5. Task 3 (Nettoyage orphelins) ← haute priorité, rapide
6. Task 9 (Nettoyage uploads) ← haute priorité, disque
7. Task 5 (Lunar persist) ← haute priorité
8. Task 7 (Astuce du jour) ← moyen, rapide
9. Task 13 (Dicton du jour) ← moyen, rapide
10. Task 8 (Saint du jour) ← moyen, rapide
11. Task 14 (Renommer nav) ← basse, 5 min
12. Task 15 (Route calendrier) ← basse
13. Task 10 (Export JSON) ← moyen
14. Task 11 (Observations) ← moyen
15. Task 12 (MediaGallery) ← moyen