36 KiB
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
.vueet.tssont dansfrontend/src/ - Table
user_settings: colonnescle(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 (12–20px, défaut 14)ui_menu_font_size: taille texte menu latéral (11–18px, défaut 13)ui_menu_icon_size: taille icônes menu (14–28px, défaut 18)ui_thumb_size: taille miniatures images/vidéos (60–200px, défaut 96)
Step 1: Modifier ReglagesView.vue — ajouter section Interface
Ajouter une nouvelle section avant la section "Général" dans ReglagesView.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 :
// --- 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 = ... :
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>):
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 :
<!-- 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
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
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 :
<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.vueligne 2JardinsView.vueJardinDetailView.vueligne 2PlantesView.vuePlantationsView.vueTachesView.vuePlanningView.vueligne 2 (max-w-3xl→max-w-5xl)ReglagesView.vueligne 2 (max-w-3xl→max-w-5xl)OutilsView.vueAstucesView.vueCalendrierView.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:
<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
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
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
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
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 :
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
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
<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 :
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFoundView.vue') },
Step 3: Rebuild et tester
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
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
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
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 :
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
sqlite3 /home/gilles/Documents/vscode/jardin/data/jardin.db "SELECT count(*) FROM lunarcalendarentry;"
# Doit être > 0 après restart
Step 6: Commit
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.tsetfrontend/src/api/gardens.tspour 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
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 :
<!-- 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
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
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
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
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.tsoufrontend/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
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" :
<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
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
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
// 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 :
<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é
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
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
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 :
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)
# Lancer le script d'abord en dry-run, puis supprimer
# NE PAS automatiser la suppression sans vérification manuelle
Step 4: Commit (message uniquement)
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 :
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":
<button @click="exportJson" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
Exporter JSON
</button>
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
docker compose build backend && docker compose up -d backend
curl http://localhost:8060/api/export | python3 -m json.tool | head -20
Step 4: Commit
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.pypour comprendre l'API - Modify:
frontend/src/views/PlantationsView.vue - Create:
frontend/src/api/observations.ts
Step 1: Lire le router observations
cat /home/gilles/Documents/vscode/jardin/backend/app/routers/observations.py
Step 2: Créer frontend/src/api/observations.ts
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
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.tsou similaire
Step 1: Créer MediaGallery.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
<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
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
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
<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
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
// Changer:
{ to: '/meteo', label: 'Météo', icon: '🌦️' },
// En:
{ to: '/meteo', label: 'Calendrier', icon: '📅' },
Step 2: Dans AppDrawer.vue, renommer
// Changer:
{ to: '/meteo', label: 'Météo' },
// En:
{ to: '/meteo', label: 'Calendrier' },
Step 3: Commit
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
// 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
git commit -m "refactor(nav): /calendrier route principale, /meteo redirect + titre CalendrierView"
Ordre d'exécution recommandé
- Task 1 (Settings sliders) ← demandé explicitement, impact immédiat
- Task 2 (Layout) ← demandé explicitement
- Task 4 (404 page) ← simple, haute priorité
- Task 6 (Grille jardin + plantation) ← demandé explicitement
- Task 3 (Nettoyage orphelins) ← haute priorité, rapide
- Task 9 (Nettoyage uploads) ← haute priorité, disque
- Task 5 (Lunar persist) ← haute priorité
- Task 7 (Astuce du jour) ← moyen, rapide
- Task 13 (Dicton du jour) ← moyen, rapide
- Task 8 (Saint du jour) ← moyen, rapide
- Task 14 (Renommer nav) ← basse, 5 min
- Task 15 (Route calendrier) ← basse
- Task 10 (Export JSON) ← moyen
- Task 11 (Observations) ← moyen
- Task 12 (MediaGallery) ← moyen