docs: README, CLAUDE.md, changelog, plans de conception

- README : fonctionnalités, installation, build, tests (302 + 20 E2E),
  couverture 94%, workflow mise à jour latest.yml, changelog v1.1.6
- CLAUDE.md : règles de collaboration, architecture, conventions
- Plans de conception : DDD, electron-updater, accouplement, toast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
POL Mickaël 2026-04-06 05:43:38 +02:00
parent 203c423f19
commit 2893013093
12 changed files with 4911 additions and 96 deletions

12
.gitignore vendored Normal file → Executable file
View File

@ -1,5 +1,17 @@
node_modules/ node_modules/
dist/ dist/
dist-vite/
dist-electron/
dist-ts/
*.log *.log
*.bak
plans-dragodinde-*.json
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Playwright E2E
.e2e-userdata/
.e2e-userdata-persistence/
test-results/
playwright-report/
# Coverage
coverage/

90
CHANGELOG.md Normal file
View File

@ -0,0 +1,90 @@
# Changelog
## v1.1.6
### Mise à jour automatique
- **Migration electron-updater** : remplacement du système custom (download HTTP + script batch) par `electron-updater` (generic provider)
- Vérification sha512 automatique des mises à jour
- Installation NSIS native (plus de script batch hack)
- Restart automatique après installation
- Code simplifié (~60 lignes vs ~200)
- Compatible Gitea : découverte via API + `latest.yml` uploadé en asset de release
- Nouveau workflow de release : uploader le `.exe` ET `latest.yml` sur Gitea
### Architecture
- Migration complète vers une architecture DDD hexagonale (Vite + TypeScript)
- Couche **domain** : entités, value objects, services purs, ports
- Couche **application** : CQRS (CommandBus / QueryBus), handlers
- Couche **infrastructure** : LocalStorage, Electron notifications, WebAudio alarm
- Couche **presentation** : composants, helpers, UIState
- Suppression du monolithe legacy, Vite est désormais le seul build
- Setup Vitest avec alias de chemins (`@domain`, `@application`, etc.)
### Nouvelles fonctionnalités
- **Timer** : session robuste (pause/reprise fiable), alarme unique, ETA niveau 200
- **Accouplement** : images des dragodindes (66 races en base64), barre de recherche par nom, filtres par génération (AND logique)
- **Réapprovisionnement** : barre de recherche, sauvegarde de workflow depuis les résultats de calcul
- **Inventaire** : barre de recherche, simulation proportionnelle multi-générations, bouton sauvegarder le workflow
- **Workflows** : filtre par génération, barre de recherche sur les noms
- **Mises à jour en temps réel** : barres de progression du workflow, compteur DD en stock de l'inventaire — se mettent à jour pendant la frappe
### Corrections de bugs
- **Recette Ebène** : les parents étaient inversés (Amande et Rousse → corrigé en Amande et Dorée + Dorée et Rousse)
- **Simulation inventaire** : l'algorithme glouton épuisait tout le stock pour le premier croisement. Remplacé par une allocation proportionnelle dynamique (chaque parent est divisé équitablement entre les croisements qui l'utilisent)
- **Dirty flag** sur tous les écrans (accouplement, réappro, inventaire, workflows) : le rAF loop à 60fps reconstruisait le DOM à chaque frame, détruisant les event listeners et empêchant les clics/inputs de fonctionner
- **Persistance inventaire** : `update-settings` ne gérait pas le champ `inventaire` — le stock était perdu à chaque changement d'écran
- **Chargement inventaire** : `get-inventaire` retournait un résultat de simulation au lieu du stock brut — l'écran se chargeait avec des données corrompues
- **Comportement focus/blur des inputs** : uniformisé sur tous les écrans — focus vide le champ, blur sans valeur restaure l'ancienne, blur avec valeur applique la nouvelle
- **ReapproView** : constructeur corrigé (commandBus manquant), bouton save-workflow branché sur la commande
- **CSP** : ajout de `img-src 'self' data:` pour les images base64 des dragodindes
- **Inventaire** : import `RACES_DATA` manquant + type `CalcCrossing``SimulationCrossing` — l'écran ne s'affichait plus
- **Baffeur/Caresseur** : exclusion mutuelle (activer l'un désactive l'autre), bouton opposé grisé dans l'UI, contrainte de signe sur la cible sérénité (négatif pour baffeur, positif pour caresseur) — corrige le timer qui affichait "terminé" avec des cibles positives + baffeur
- **Paramètres** : écran refait à l'identique du monolithe (boutons compacts avec emojis, modal ntfy avec QR codes et génération automatique du topic)
- **Sons d'alarme** : fréquences et types d'oscillateurs corrigés pour correspondre au monolithe (arpège 440/554/659/880, fanfare triangle 6 notes, cloche 440/880/1320/1760, pulsation 5 pulses)
- **Bouton Test son** : ne fonctionnait pas (passait par IPC sans écouteur) — joue maintenant directement via WebAudio
- **CSP** : ajout de `img-src https://api.qrserver.com` pour les QR codes ntfy
### Tests
- Tests unitaires pour `StockSimulator` (simulation proportionnelle : 13 cas)
- Tests unitaires pour `SaveWorkflow` et `UpdateSettings` avec inventaire
- Tests de régression pour la simulation inventaire (scénario du monolithe)
- Test de la recette Ebène corrigée
- Mise à jour des tests `GetInventaire` (stock brut au lieu de résultat calculé)
### Refonte graphique "Obsidienne" (MD3)
- **Design system Obsidienne** : glassmorphism, tokens Material Design 3 (primary, secondary, surface, outline…), polices Manrope/Inter/Plus Jakarta Sans
- **Nouveau fichier `obsidienne.css`** (~2100 lignes) : design MD3 pour tous les composants
- **Migration emojis → Material Symbols Outlined** : toutes les icônes (stats, navigation, actions)
- **Variables CSS MD3** : 37 tokens ajoutés dans `variables.css` (`--md-primary`, `--md-surface-container`, etc.)
- **Sidebar** : refonte complète avec logo Obsidienne, sections organisées (Principal, Enclos, Outils, Paramètres), icônes Material Symbols, pastille de statut timer, version dans le footer — ouvert par défaut
- **App Shell** : nouveau layout `app-shell` (sidebar + main-area), header avec hamburger, suppression de l'overlay sidebar
- **Dashboard** : grille KPI (bébés, DD actives, couples, taux de réussite, races), bouton reset stats intégré, aperçu enclos
- **EnclosView** : barres de jauge visuelles avec gradient par tier, bouton timer Material (DÉMARRER/PAUSE/REPRENDRE), zone "Alarme dans" réorganisée, bouton reset timer ajouté
- **DragodindeCard** : stats avec icônes Material, jauge XP restructurée avec niveau + % + ETA
- **Accouplement** : refonte de wizard 3 étapes → layout single-page (Parent 1 | Coeur+inputs | Parent 2 + grille de races en dessous), glassmorphism, chips génération pill, preview du bébé au centre
- **UpdateBanner** : aligné sur le nouveau design MD3
- **Drag & drop accouplement** : les cartes de race sont glissables vers les zones Parent 1 / Parent 2, avec feedback visuel (halo violet), vérification de compatibilité partenaire au drop
- **Numérotation smart des enclos** : la création comble les trous (supprimer Enclos 2 puis créer → "Enclos 2" au lieu de "Enclos N+1")
- **Icône Windows native** : migration `icon.png``icon.ico` pour la fenêtre et la tray
- **10 tests de régression `level-target-timer`** : vérifient que `levelTarget` affecte correctement le countdown mangeoire, `enclosGlobalState`, `calcLevelEtaLive`, et le flag `done`
- **Auto-scroll drag & drop** : le conteneur remonte automatiquement quand on drag une race vers le haut de l'écran
- **Correction scroll petit écran** : compensation du zoom CSS pour que le scroll atteigne le bas de la grille
- Retrait des labels "MÂLE REQUIS" / "FEMELLE REQUISE" des panneaux parent
## v1.1.5
- Ajout de nouvelles features.
## v1.1.4
- Ajout de nouvelles features et correction de bugs.
## v1.1.3
- Correctif de bug mineur + features.

101
CLAUDE.md
View File

@ -2,37 +2,81 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Règles de collaboration
- **Toujours répondre et écrire en français**
- **Ne jamais modifier les fichiers de tests sans le signaler explicitement** à l'utilisateur avant de le faire
- **Toujours écrire des tests** (unitaires + fonctionnels/régression) lors de toute correction de bug ou ajout de feature — les tests font partie de la livraison, pas une étape optionnelle
- **Toujours mettre à jour `algorithmes.md`** quand un algorithme ou une formule de calcul change
- **Ne jamais utiliser de taux fixe pour les jauges** — toujours utiliser `gainedIn` / `timeToGain` avec traversée des tiers (erreur historique du monolithe)
- **Signaler explicitement** tout changement qui touche aux snapshots du timer (`snapGauges`, `snapStats`, `gaugeRecharges`) car cela peut affecter tous les calculs en cours de session
- **Les tests ne tournent pas en WSL** — toujours rappeler à l'utilisateur de lancer `npm test` depuis un terminal Windows
## Project Overview ## Project Overview
Minuteur Dragodinde is a Windows desktop app (Electron) for managing Dragodinde breeding timers in Dofus 3. French-language UI. Minuteur Dragodinde is a Windows desktop app (Electron + Vite + TypeScript) for managing Dragodinde breeding timers in Dofus 3. French-language UI.
## Commands ## Commands
- **Dev**: `npm start` (runs `electron .`) - **Dev**: `npm start` (runs Vite dev server + Electron via `vite-plugin-electron`)
- **Build**: `npm run build` (produces NSIS installer in `dist/`) - **Build**: `npm run build` (Vite build + electron-builder → NSIS installer in `dist/`)
- **Test**: `npm test` (Vitest, run from Windows — native bindings incompatible with WSL)
- **Test watch**: `npm run test:watch`
- **Build scripts**: `build.bat` (auto-elevates to admin) or `build.ps1` for Windows - **Build scripts**: `build.bat` (auto-elevates to admin) or `build.ps1` for Windows
No test framework or linter is configured.
## Architecture ## Architecture
**Single-page Electron app with no bundler.** All source code lives in 4 files: **DDD hexagonal architecture** with Vite + TypeScript. Four layers:
- `main.js` — Electron main process: window management, system tray, native notifications, ntfy push notifications (mobile), auto-update via Gitea Releases API, IPC handlers ```
- `preload.js` — Context bridge exposing `window.electronAPI` (alarm, notifications, ntfy, version, update channels) src/
- `src/index.html`**Monolithic ~2200-line file** containing all HTML, CSS, and JS in one file. This is the entire renderer process. domain/ — Entities, value objects, domain services (pure TS, no deps)
- `icon.png` — App icon (256x256) application/ — Command handlers, query handlers, CommandBus/QueryBus
infrastructure/ — LocalStorageRepository, ElectronNotification, WebAudioAlarm
presentation/ — Components, helpers, UIState, entry point (index.ts)
```
### Renderer architecture (src/index.html) ### Domain layer
All application logic is in `<script>` tags inside index.html. Key concepts: - **`entities/Enclos.ts`** — Enclos entity with `TimerData` (includes `gaugeRecharges: Record<string, GaugeRecharge[]>`)
- **`entities/Dragodinde.ts`** — DD entity with stats, targets, `levelTarget`, `sereniteTarget`
- **`services/GaugeCalculator.ts`** — Core pure functions: `gainedIn`, `timeToGain`, `gaugeAfter`, `elapsed`, `computeGaugeState`
- **`value-objects/GaugeType.ts`** — `GAUGE_DEFS`, `STAT_DEFS`, `DEFAULT_TARGETS` (mangeoire default = 100, but UI uses 200 when `levelTarget` is null)
- **`value-objects/XpTable.ts`** — `xpForLevel`, `levelFromXp`
- **`value-objects/Tier.ts`** — `tierNum`, `tierRate`
- **State object `S`**: Central state with `enclos` array, `activeId`, settings. Persisted to `localStorage`. ### Application layer
- **Enclos**: Up to 6 independent pens, each holding up to 10 Dragodindes (DDs). Enclos and DDs are drag-and-drop reorderable.
- **Gauges**: 6 gauge types (baffeur, caresseur, foudroyeur, abreuvoir, dragofesse, mangeoire/XP) with tier-based progression rates (tiers 1-4 based on gauge level thresholds at 40k/70k/90k). Commands: `StartTimer`, `StopTimer`, `CompleteTimer`, `ResetTimer`, `CreateEnclos`, `DeleteEnclos`, `AddDragodinde`, `RemoveDragodinde`, `UpdateGauge` (toggle + level), `RechargeGauge`, `RegisterAccouplement`, `UpdateSettings`, `ResetStats`, `ReorderEnclos`, `UpdateWorkflow`, `DeleteWorkflow`, `EnclosActions` (clear, rename, reset-timer), `DragodindeActions` (rename, update-stat, seren-target, level-target, reorder)
- **Timer system**: Per-enclos timers with snapshot-based calculation. `elapsed()` computes time since start, `gaugeAfter()`/`gainedIn()`/`timeToGain()` compute gauge progression.
- **Rendering**: Imperative DOM manipulation via `render()` function — no framework. Queries: `GetDashboard`, `GetEnclosDetail`, `GetTimerState`, `GetBreedingOptions`, `GetReapproTree`, `GetInventaire`, `GetSettings`, `GetWorkflows`
- **Dashboard/Stats views**: Special `activeId` values (`'dashboard'`, `'stats'`) for overview and statistics pages.
### Presentation layer
- **`components/App.ts`** — Root component, animation loop (`requestAnimationFrame`), CommandBus/QueryBus wiring
- **`components/EnclosView.ts`** — Main timer view: gauge inputs (with `input` listener for real-time updates), DD grid, "Alarme dans" display
- **`components/DragodindeCard.ts`** — Per-DD card: stat pills, targets (all with `input` listeners for real-time preview)
- **`helpers/gauge-live.ts`** — All live calculation helpers: `computeGaugeLive`, `enclosGaugeCurGl`, `enclosGlobalState`, `calcSerenEtaLive`, `calcLevelEtaLive`
### Key data flows
**Timer start** (fresh): snapshots `gaugeLevels``snapGauges`, DD stats → `snapStats`, clears `gaugeRecharges`
**Timer resume** (from pause): accumulates `pausedMs += now - pausedAt`, preserves snapshots
**Gauge recharge** (during timer): pushes `{ atSec, level }` to `gaugeRecharges[gid]`, updates `gaugeLevels[gid]`
**Live display**: `requestAnimationFrame` loop → `enclosGlobalState(enc)` → per-DD `computeGaugeLive` → DOM updates
### Timer calculation core — `computeGaugeState`
The central calculation function. Handles:
- Multiple gauge recharges during a session (segments between each recharge)
- Freeze at absolute stat cap (sérénité ±5000, end/mat/amour 20000, niveau 200)
- Returns `{ gained, curGl, effectiveEl }`
**Pre-start preview**: `enclosGlobalState` computes even when timer not started (`started = false`), using current `gaugeLevels` as `startGl` and `el = 0`. "Alarme dans" updates in real-time as the user types gauge values and DD targets.
**XP gauge (mangeoire)**: when the target is unreachable in a single gauge drain, countdown shows drain time instead of `∞`. Formula: `timeToGain(curGl, Math.min(xpRestante, curGl))` — consistent with "Vide en" display.
**Default level target**: when `dd.levelTarget === null`, the XP gauge defaults to targeting level 200.
### IPC channels ### IPC channels
@ -47,9 +91,24 @@ Checks Gitea Releases API on startup (after 3s) and hourly. Downloads NSIS Setup
When `!app.isPackaged`, userData is stored in `MinuteurDragodinde-DEV` (isolated from installed app) and a DEV badge is injected into the UI. When `!app.isPackaged`, userData is stored in `MinuteurDragodinde-DEV` (isolated from installed app) and a DEV badge is injected into the UI.
## Tests
```
tests/
unit/domain/ — GaugeCalculator, Enclos, Dragodinde, XpTable, etc.
unit/application/ — commands.test.ts, queries.test.ts, CommandBus.test.ts
unit/infrastructure/
functional/ — breeding-workflow, enclos-management, timer-workflow
regression/ — gauge-tier-calculation, gauge-recharge, xp-timer-display, etc.
```
Vitest with path aliases (`@domain`, `@application`, `@infrastructure`, `@presentation`). Run with `npm test` from Windows.
## Key conventions ## Key conventions
- All UI text is in French - All UI text is in French
- No external JS dependencies in renderer — everything is vanilla JS - TypeScript strict mode — no `any` unless unavoidable
- CSS uses custom properties defined in `:root` (color theme: dark purple/gaming aesthetic) - Immutable domain entities (functions return new objects)
- Electron config: `contextIsolation: true`, `nodeIntegration: false`, `backgroundThrottling: false` - Commands mutate shared `state` object directly (no Redux-style reducers)
- `repo.save(state)` called after every state mutation
- CSS uses custom properties in `:root` (dark purple/gaming aesthetic), component styles in `src/presentation/styles/`

173
README.md Normal file → Executable file
View File

@ -1,23 +1,42 @@
# ⚔ Minuteur Dragodinde — Dofus 3 # ⚔ Obsidienne — Minuteur Dragodinde (Dofus 3)
Outil de gestion d'élevage de Dragodindes pour Dofus 3. Application desktop Windows de gestion d'élevage de Dragodindes pour Dofus 3.
Application desktop Windows construite avec Electron. Construite avec **Electron + Vite + TypeScript** en architecture **DDD hexagonale**.
🔗 **Repo** : https://gitea.mickael-pol.fr/mickael/dd-timer 🔗 **Repo** : https://gitea.mickael-pol.fr/mickael/dd-timer
## Fonctionnalités ## Fonctionnalités
### Élevage & Timers
- 🐦 Gestion de **6 enclos indépendants** avec jusqu'à 10 Dragodindes chacun - 🐦 Gestion de **6 enclos indépendants** avec jusqu'à 10 Dragodindes chacun
- ⏱ **Timer en temps réel** avec calcul automatique par tier de jauge (1→4) - ⏱ **Timer en temps réel** avec calcul automatique par tier de jauge (1→4)
- 📊 **Dashboard** vue d'ensemble multi-enclos - 🐉 **6 jauges** : Baffeur, Caresseur, Foudroyeur, Abreuvoir, Dragofesse, Mangeoire (XP)
- 🔔 **Notifications natives Windows** même application en arrière-plan - 🔄 **Recharge de jauge** en cours de session avec recalcul multi-segments
- 🔊 **4 sons d'alarme** au choix (Arpège, Pulsation, Fanfare, Cloche) - 🎯 **Cibles sérénité et niveau** par Dragodinde avec ETA en temps réel
- 🐉 **Jauges** : Baffeur, Caresseur, Foudroyeur, Abreuvoir, Dragofesse, Mangeoire (XP)
- 🖱 **Drag & drop** des enclos et des Dragodindes pour les réordonner - 🖱 **Drag & drop** des enclos et des Dragodindes pour les réordonner
### Reproduction
- 💕 **Accouplement** — sélection des parents avec déduction automatique du bébé (63 races créables)
- 🔬 **Réapprovisionnement** — arbre de reproduction avec genres ♂/♀ et inversions
- 📦 **Inventaire** — stock ♂/♀ par race avec simulation proportionnelle multi-générations
- 🔀 **Workflows** — plans de reproduction sauvegardables avec export/import sélectif
### Statistiques & Dashboard
- 📊 **Dashboard** — vue d'ensemble multi-enclos avec KPIs globaux
- 📈 **Statistiques** — naissances par jour, répartition par race/génération, taux de réussite, meilleurs couples, races manquantes, activité par jour de la semaine
- 🔍 **Filtres de période** — 7j, 14j, 30j, 3 mois, tout l'historique avec comparaison delta
### Notifications & Paramètres
- 🔔 **Notifications natives Windows** même application en arrière-plan
- 📱 **Notifications mobiles** via ntfy (serveur self-hosted) avec QR codes de configuration
- 🔊 **4 sons d'alarme** au choix (Arpège, Pulsation, Fanfare, Cloche)
- ⬆ **Mise à jour automatique** via Gitea Releases - ⬆ **Mise à jour automatique** via Gitea Releases
- 📱 **Notifications mobiles** via ntfy (serveur self-hosted)
- 💾 Sauvegarde automatique locale - 💾 Sauvegarde automatique locale
### Design
- 🎨 **Design system Obsidienne** — glassmorphism, Material Design 3, Material Symbols Outlined
- 🌙 Interface sombre avec thème violet/gaming
## Installation (utilisateurs) ## Installation (utilisateurs)
1. Télécharger `Minuteur Dragodinde Setup x.x.x.exe` depuis la section [Releases](https://gitea.mickael-pol.fr/mickael/dd-timer/releases) 1. Télécharger `Minuteur Dragodinde Setup x.x.x.exe` depuis la section [Releases](https://gitea.mickael-pol.fr/mickael/dd-timer/releases)
@ -32,44 +51,150 @@ Application desktop Windows construite avec Electron.
### Prérequis ### Prérequis
- [Node.js LTS](https://nodejs.org) - [Node.js LTS](https://nodejs.org)
### Compiler ### Commandes
```bash ```bash
# Double-cliquer sur build.bat (admin auto) npm install # Installer les dépendances
# ou manuellement : npm start # Dev (Vite + Electron)
npm install npm test # Tests unitaires (Vitest, depuis Windows)
npm run build npm run test:e2e # Tests E2E (Playwright + Electron)
npm run build # Build (Vite + electron-builder → NSIS installer)
``` ```
L'installeur est généré dans `dist/`. Ou double-cliquer sur `build.bat` (admin auto). L'installeur est généré dans `dist/`.
### Tests
**302 tests unitaires/fonctionnels/régression** (Vitest) — couverture de code **94%** (v8).
| Couche | Statements | Branches | Functions | Lines |
|--------|-----------|----------|-----------|-------|
| Domain | 94100% | 80100% | 92100% | 85100% |
| Application | 92100% | 76100% | 100% | 96100% |
| Infrastructure | 100% | 100% | 100% | 100% |
**20 tests E2E** (Playwright + Electron) couvrant :
- **Navigation sidebar** (8 tests) — tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres
- **Cycle de vie du timer** (5 tests) — activation jauge, démarrage, pause/reprise, reset
- **Recharge de jauge** (2 tests) — mise à jour "Alarme dans" et barre de jauge en temps réel
- **Workflow d'accouplement** (5 tests) — sélection parents, résultat bébé, enregistrement, filtres génération, recherche
- **Persistance des données** (1 test) — survie des données après fermeture/réouverture
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL. Les tests E2E nécessitent un `npm run build` préalable.
## Publier une nouvelle version ## Publier une nouvelle version
L'application utilise **electron-updater** avec un fichier `latest.yml` pour la mise à jour automatique. Ce fichier est généré automatiquement par `electron-builder` lors du build et contient la version, le nom du fichier et le hash sha512.
```bash ```bash
# 1. Modifier la version dans package.json ("version": "1.x.x") # 1. Modifier la version dans package.json ("version": "1.x.x")
# 2. Committer et tagger # 2. Build l'application
npm run build
# → Génère dans dist/ :
# - Minuteur Dragodinde Setup 1.x.x.exe
# - latest.yml (version + sha512, requis par electron-updater)
# 3. Committer et tagger
git add . git add .
git commit -m "v1.x.x - description" git commit -m "v1.x.x - description"
git tag v1.x.x git tag v1.x.x
git push && git push --tags git push && git push --tags
# 3. Sur Gitea : Releases → Nouvelle release → tag v1.x.x → attacher Setup.exe # 4. Sur Gitea : Releases → Nouvelle release → tag v1.x.x
# Attacher les 2 fichiers :
# - Minuteur Dragodinde Setup 1.x.x.exe
# - latest.yml
``` ```
## Structure > **Important** : le fichier `latest.yml` est **obligatoire** dans la release Gitea. Sans lui, electron-updater ne peut pas vérifier ni télécharger la mise à jour. L'app vérifie les mises à jour au démarrage (après 3s) via l'API Gitea Releases, puis pointe electron-updater vers le tag de la release pour lire `latest.yml`.
## Architecture
``` ```
dd-timer/ src/
├── src/index.html # Interface (HTML/CSS/JS) ├── domain/ — Entités, value objects, services purs (aucune dépendance)
├── main.js # Processus principal Electron + auto-update ├── application/ — CommandBus/QueryBus, commandes et requêtes (CQRS)
├── preload.js # Bridge Electron ↔ renderer ├── infrastructure/ — LocalStorage, Electron IPC, notifications, audio
├── icon.png # Icône (256x256) └── presentation/ — Composants UI, helpers live, styles CSS, état UI
├── package.json # Config et dépendances
└── build.bat # Script de build Windows (admin auto)
``` ```
## Changelog ## Changelog
### v1.1.6
#### Architecture
- 🏗 **Refonte architecture DDD hexagonale** — migration complète du monolithe `index.html` vers une architecture en couches (domain / application / infrastructure / presentation) avec Vite + TypeScript + Vitest
- **Domain** : entités (`Enclos`, `Dragodinde`), value objects (`GaugeType`, `Tier`, `XpTable`, `Race`), services purs (`GaugeCalculator`, `BreedingService`)
- **Application** : CQRS — `CommandBus`, `QueryBus`, 15+ commandes, 8+ requêtes
- **Infrastructure** : `LocalStorageRepository`, `ElectronNotification`, `WebAudioAlarm`
- **Presentation** : composants TypeScript, helpers live, CSS modulaire
- **Tests** : suites unitaires, fonctionnelles et de régression (Vitest)
#### Design Obsidienne
- 🎨 **Refonte graphique complète "Obsidienne"** — nouveau design system glassmorphism avec tokens Material Design 3, polices Manrope/Inter/Plus Jakarta Sans
- **`obsidienne.css`** (~4500 lignes) : feuille de styles MD3 complète, 37+ variables CSS tokens
- **Material Symbols Outlined** : migration de tous les emojis vers des icônes vectorielles
- **Sidebar** : logo, sections organisées (Principal, Enclos, Outils), pastille de statut timer, Paramètres dans le footer
- **App Shell** : layout `app-shell` (sidebar + main-area), header hamburger
- **Dashboard** : grille KPI (bébés, DD actives, couples, taux de réussite, races)
- **EnclosView** : barres de jauge visuelles avec gradient par tier, bouton timer Material
- **DragodindeCard** : stats avec icônes Material, jauge XP (niveau + % + ETA)
- **Accouplement** : layout single-page (Parent 1 | Coeur+inputs | Parent 2 + grille de races), glassmorphism, chips génération, drag & drop
- **Réapprovisionnement** : arbre de reproduction redesigné avec cartes glassmorphism
- **Inventaire** : grille de stock ♂/♀ redesignée, simulateur avec résultats visuels
- **Workflows** : cartes workflow avec barres de progression, export/import sélectif
- **Statistiques** : écran complet avec graphiques, donut, heatmap, avatars de races
- **Paramètres** : cartes son avec sélection visuelle, toggle notifications, modal ntfy redesignée
- **UpdateBanner** : aligné sur le design MD3
#### Nouvelles fonctionnalités
- ✨ **Écran Statistiques** — naissances par jour (barres), répartition des races (donut), naissances par génération, activité par jour de la semaine (heatmap), taux de réussite par race, meilleurs couples (top 10), races manquantes groupées par génération, détail par race
- ✨ **Filtres de période** sur les statistiques — 7j, 14j, 30j, 3 mois, tout l'historique avec comparaison delta (+/) par rapport à la période précédente
- ✨ **Export/Import de workflows** — sélection individuelle ou globale, dialogue natif Windows, import avec déduplication des IDs
- ✨ **Drag & drop accouplement** — cartes de race glissables vers Parent 1 / Parent 2, feedback visuel et vérification de compatibilité
- ✨ **Images des dragodindes** (66 races en base64) sur tous les écrans
- ✨ **Barres de recherche** sur accouplement, réapprovisionnement, inventaire et workflows
- ✨ **Sauvegarde de workflow** depuis les résultats de réappro et d'inventaire
- ✨ **Simulation proportionnelle inventaire** — allocation dynamique multi-générations
- ✨ **Filtres par génération** sur les workflows + mises à jour en temps réel
- ✨ **Numérotation smart des enclos** — comble les trous lors de la création
#### Timer & Jauges
- ✨ **Calcul de jauge multi-segments** — gère les recharges intermédiaires et le gel au cap absolu
- ✨ **Recharge de jauge en cours de session** — bouton rechargement avec snapshot `{ atSec, level }`
- ✨ **Session timer robuste** — continue en temps réel après complétion automatique
- ✨ **Alarme unique** — se déclenche une seule fois quand toutes les cibles sont atteintes
- ✨ **Détection de complétion via `setInterval`** — fonctionne même en arrière-plan
- ✨ **Jauges débloquées après session** — sélection de nouvelles jauges avant la session suivante
- ✨ **Commande `nouvelle-fournee`** — réinitialise timer + jauges + DD
- ✨ **ETA niveau 200** — barre de progression et pourcentage en temps réel
- ✨ **Boutons reset cibles** — réinitialise sérénité ou niveau depuis la card DD
#### Corrections
- 🐛 **Recette Ebène** — parents inversés corrigés
- 🐛 **Simulation inventaire** — algorithme glouton → allocation proportionnelle
- 🐛 **Dirty flag** — le rAF loop ne reconstruit plus le DOM inutilement
- 🐛 **Persistance inventaire**`update-settings` gère le champ `inventaire`
- 🐛 **Baffeur/Caresseur** — exclusion mutuelle, contrainte de signe sur cible sérénité
- 🐛 **Sons d'alarme** — fréquences corrigées, bouton Test fonctionnel via WebAudio
- 🐛 **CSP** — ajout `img-src data:` et `https://api.qrserver.com`
- 🐛 **Statistiques** — les races Gen 1 (Rousse, Dorée, Amande) ne sont plus comptées car non créables (63 races au lieu de 66)
- 🐛 **Export workflows** — dialogue de sauvegarde pointe vers Documents par défaut
- 🐛 **Sidebar footer** — corrigé le footer masqué par la barre des tâches Windows (`calc(100vh / 1.15)` pour compenser le zoom global)
- 🐛 **Notifications ntfy** — migration de l'API headers vers l'API JSON pour le support UTF-8 (corrige les caractères accentués "pr?tes" → "prêtes")
- 🐛 **Redémarrage timer** — empêche le redémarrage d'un timer quand toutes les cibles sont déjà atteintes (évite l'alarme en boucle après complétion)
- 🐛 **Nouvelle fournée** — le bouton n'apparaît que si toutes les DD ont 20000 en maturité, endurance et amour
- 🐛 **Timer jauges vides** — interdit le démarrage du timer quand toutes les jauges actives sont à 0
- 🐛 **Jauge vide en cours de session** — affichage ∞ dans le countdown, alerte visuelle "⚠ Rechargez la jauge" avec animation pulsante, badge jauge vide sur le dashboard
- 🐛 **Recharge jauge temps réel** — le countdown "Alarme dans" et les stats se mettent à jour en temps réel pendant la saisie (event `input` en plus du `blur`), avec consolidation des recharges proches (< 2s) pour éviter la pollution du tableau
- 🐛 **Recharge en pause** — les recharges de jauge sont maintenant acceptées quand le timer est en pause (pas uniquement pendant l'exécution)
#### Technique
- ⬆ **Migration electron-updater** — vérification sha512 via `latest.yml`, installation NSIS native, restart auto
- 🎨 **Icône Windows native** — migration `icon.png``icon.ico`
- 🧪 **302 tests** (unitaires, fonctionnels, régression) — couverture **94%** via Vitest + v8
- 🧪 **20 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance
### v1.1.5 ### v1.1.5
- ✨ **Onglet Accouplement** — selection des 2 parents, deduction automatique du bebe, saisie du nombre de couples et bebes obtenus pour alimenter les statistiques globales - ✨ **Onglet Accouplement** — selection des 2 parents, deduction automatique du bebe, saisie du nombre de couples et bebes obtenus pour alimenter les statistiques globales
- ✨ **Sidebar navigation** — menu hamburger avec panneau lateral overlay (Dashboard, Enclos, Accouplement, Reappro, Inventaire, Workflows, Parametres) - ✨ **Sidebar navigation** — menu hamburger avec panneau lateral overlay (Dashboard, Enclos, Accouplement, Reappro, Inventaire, Workflows, Parametres)

View File

@ -50,6 +50,8 @@ C'est l'inverse de `gainedIn`. On parcourt les paliers du haut vers le bas :
Si la jauge se vide complètement avant d'atteindre l'objectif → retourne `Infinity` (impossible). Si la jauge se vide complètement avant d'atteindre l'objectif → retourne `Infinity` (impossible).
**Cas "vidange complète"** : `timeToGain(lvl, lvl)` donne le temps pour vider entièrement la jauge. C'est cette formule qu'utilisent à la fois l'affichage "Vide en" et le timer XP quand la cible est hors de portée d'une seule charge.
--- ---
## 4. Niveau de jauge après X secondes — `gaugeAfter(lvl, sec)` ## 4. Niveau de jauge après X secondes — `gaugeAfter(lvl, sec)`
@ -60,9 +62,11 @@ Même logique que `gainedIn`, mais au lieu de compter les points donnés, on sou
--- ---
## 5. Temps écoulé — `elapsed(enc)` ## 5. Temps écoulé — `elapsed` et `elapsedLive`
Calcule les secondes écoulées depuis le démarrage du timer d'un enclos, en excluant les pauses : ### `elapsed(timer)` — temps figé pour l'affichage
Calcule les secondes écoulées depuis le démarrage du timer d'un enclos, en excluant les pauses. **Se fige** quand le timer est en pause ou terminé :
``` ```
Si en cours : (maintenant - démarrage - temps_en_pause) / 1000 Si en cours : (maintenant - démarrage - temps_en_pause) / 1000
@ -70,9 +74,64 @@ Si en pause : (moment_pause - démarrage - temps_en_pause) / 1000
Si non démarré : 0 Si non démarré : 0
``` ```
Utilisé pour : l'affichage "Temps écoulé", le dashboard.
### `elapsedLive(enc)` — temps réel après complétion automatique
Retourne un temps qui **continue de progresser en temps réel** même après que la session s'est terminée automatiquement. Cela permet aux jauges de continuer à se vider en arrière-plan après l'alarme.
```
Si enc.alerted['__done__'] est positionné :
→ (Date.now() - startTime - pausedMs) / 1000 (jamais figé)
Sinon :
→ elapsed(enc.timer) (comportement normal, fige sur pause)
```
**`enc.alerted['__done__']`** est posé par la commande `complete-timer` quand toutes les cibles sont atteintes. Ce flag distingue une "fin naturelle de session" (jauges continuent) d'une "pause manuelle" (jauges figées).
Utilisé pour : tous les calculs de jauges dans `computeGaugeLive`, `enclosGaugeCurGl`, `calcSerenEtaLive`, `calcLevelEtaLive`.
--- ---
## 6. Calcul unifié d'une jauge — `computeGaugeLive(enc, dd, gid, el, started)` ## 6. Gel de jauge au cap absolu des stats et calcul par segments — `computeGaugeState`
### 6.1 Gel au cap absolu
Quand une **stat** atteint sa **limite absolue** (sérénité ±5000, endurance/maturité/amour 20 000), la jauge correspondante **s'arrête de se vider** — il n'y a plus rien à donner à la DD.
**Points jusqu'au cap** (`ptsToAbsCap`) :
- Stat normale : `dir>0 ? (statMax - startSt) : (startSt - statMin)` — la jauge continue **même après la cible**, jusqu'au cap absolu de la stat
- XP / mangeoire : `xpForLevel(200) - xpForLevel(niveauDépart)` — la jauge gèle uniquement au niveau 200, **jamais à la cible XP**
**Comportement après la cible** : les jauges ne s'arrêtent PAS quand la cible d'une DD est atteinte. Elles continuent de se vider jusqu'à ce que la stat atteigne son cap absolu (ex: sérénité à -5000, même si la cible était -60). Cela garantit que l'affichage reste cohérent et que les DDs continuent de progresser en fond.
**Affichage global de la jauge de stat (enclos)** : la jauge de stat se fige quand la **dernière** DD atteint son cap absolu. La mangeoire continue de se vider jusqu'au niveau 200.
### 6.2 Calcul par segments avec recharges — `computeGaugeState(startGl, recharges, ptsAllowed, el)`
Quand le joueur recharge une jauge en cours de session, le calcul se découpe en **segments** :
```
Segment 1 : startGl → recharge[0].atSec (ou fin du timer si pas de recharge)
Segment 2 : recharge[0].level → recharge[1].atSec
...
Segment N : recharge[N-1].level → el
```
Pour chaque segment :
1. Calculer la durée du segment
2. Calculer les points gagnés dans ce segment avec `gainedIn`
3. Vérifier si le cap (`ptsAllowed`) est atteint → si oui, calculer le moment exact du gel et stopper
4. Si non, passer au segment suivant avec le nouveau niveau de jauge après recharge
**Retourne** : `{ gained, curGl, effectiveEl }`
- `gained` : points totaux accumulés sur tous les segments
- `curGl` : niveau de jauge au moment du gel (ou à `el` si pas de gel)
- `effectiveEl` : temps réel de fonctionnement (≤ el, limité par le gel)
---
## 7. Calcul unifié d'une jauge — `computeGaugeLive(enc, dd, gid, el, started)`
C'est le **cœur du système**. Pour chaque DD et chaque jauge active, il calcule en temps réel : C'est le **cœur du système**. Pour chaque DD et chaque jauge active, il calcule en temps réel :
- La stat estimée actuelle - La stat estimée actuelle
@ -80,30 +139,37 @@ C'est le **cœur du système**. Pour chaque DD et chaque jauge active, il calcul
- Le % de progression - Le % de progression
- Le countdown restant - Le countdown restant
> **Note sur le paramètre `el`** : bien que la signature accepte un paramètre `el` (elapsed), il est **ignoré en interne**. La fonction appelle systématiquement `elapsedLive(enc)` pour ses calculs, ce qui garantit que les jauges continuent de progresser après la fin de session. Le paramètre `el` est conservé dans la signature pour la compatibilité des appels depuis `enclosGlobalState`.
### Pour les jauges normales (sérénité, endurance, maturité, amour) : ### Pour les jauges normales (sérénité, endurance, maturité, amour) :
1. On récupère le **snapshot** de la jauge et de la stat au moment du démarrage du timer 1. On récupère le **snapshot** de la jauge et de la stat au moment du démarrage du timer
2. On calcule les points gagnés depuis le snapshot : `gainedIn(snapshotJauge, tempsÉcoulé)` 2. On appelle `computeGaugeState(startGl, recharges, ptsToAbsCap, elLive)` pour obtenir les points gagnés en tenant compte des recharges et du gel
3. On applique la direction (`dir`) : 3. On applique la direction (`dir`) :
- `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte - `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte
- `dir = -1` (baffeur) : stat descend - `dir = -1` (baffeur) : stat descend
4. On clamp la stat dans ses bornes (ex: sérénité entre -5000 et +5000) 4. On clamp la stat dans ses bornes (ex: sérénité entre -5000 et +5000)
5. On calcule le temps total et le countdown via `timeToGain` 5. On calcule le temps total et le countdown via `timeToGain`
### Pour la mangeoire (XP) — modèle spécial : ### Pour la mangeoire (XP) — même modèle que les autres jauges :
L'XP utilise un **modèle linéaire constant** car le joueur est supposé **recharger** la jauge manuellement : La mangeoire se vide exactement comme les autres jauges (même tiers, même dégression). L'XP suit le même algorithme :
1. On détermine le taux fixe = `tierRate(snapshotJauge)` au démarrage 1. XP gagnée = via `computeGaugeState` (tient compte des recharges et du gel à niveau 200)
2. XP gagnée = `nombre_de_ticks × taux` (pas de dégression) 2. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)`
3. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)` 3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. Countdown = `ceil(xp_restante / taux) × 10 sec` 4. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))` — si la cible dépasse la capacité de la jauge, on affiche le temps de vidange complète
**Pourquoi un modèle différent ?** Parce que pour les stats, la jauge se vide naturellement. Pour l'XP, le joueur remet des croquettes (recharge la mangeoire), donc le taux reste constant. **Cible XP par défaut** : si `dd.levelTarget === null`, on cible le niveau 200.
**Exemple** : mangeoire à 85 000 (tier 3) pendant 7 000 sec (700 ticks) :
- 500 ticks à 30 XP/tick (tier 3, 70k→85k) = 15 000 XP, jauge → 70 000
- 200 ticks à 20 XP/tick (tier 2) = 4 000 XP, jauge → 66 000
- Total = **19 000 XP** (contre 21 000 XP avec un taux fixe erroné)
--- ---
## 7. Table d'XP et niveaux ## 8. Table d'XP et niveaux
### Table XP_RAW ### Table XP_RAW
@ -124,7 +190,7 @@ Les valeurs sont **cumulatives** (pas incrémentales).
--- ---
## 8. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive` ## 9. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive`
### Version statique (`calcSerenEta`) ### Version statique (`calcSerenEta`)
1. Calcule `diff = cible - sérénité actuelle` 1. Calcule `diff = cible - sérénité actuelle`
@ -133,39 +199,98 @@ Les valeurs sont **cumulatives** (pas incrémentales).
4. Temps = `timeToGain(niveauJauge, |diff|)` 4. Temps = `timeToGain(niveauJauge, |diff|)`
### Version live (`calcSerenEtaLive`) ### Version live (`calcSerenEtaLive`)
1. Utilise `computeGaugeLive` pour obtenir la sérénité estimée en temps réel 1. Utilise `computeGaugeState` pour obtenir la sérénité estimée en temps réel (avec recharges et gel)
2. Recalcule le diff depuis cette estimation 2. Recalcule le diff depuis cette estimation
3. Calcule le temps restant avec les points encore à parcourir 3. Calcule le temps restant avec les points encore à parcourir
--- ---
## 9. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive` ## 10. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive`
### Version statique (`calcLevelEta`) ### Version statique (`calcLevelEta`)
1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)` 1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)`
2. Taux fixe = `tierRate(niveauMangeoire)` 2. Cible = `dd.levelTarget ?? 200`
3. Temps = `ceil(xpNécessaire / taux) × 10 sec` 3. Temps = `timeToGain(niveauMangeoire, Math.min(xpNécessaire, niveauMangeoire))`
### Version live (`calcLevelEtaLive`) ### Version live (`calcLevelEtaLive`)
1. Utilise `computeGaugeLive` pour obtenir le niveau estimé actuel 1. Cible = `dd.levelTarget ?? 200`
2. XP restante = `xpForLevel(cible) - xpForLevel(niveauEstimé)` 2. XP gagnée via `computeGaugeState` (avec recharges et gel)
3. Taux fixe = `tierRate(snapshotMangeoire)` 3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
4. Countdown = `ceil(xpRestante / taux) × 10 sec` 4. XP restante = `xpForLevel(cible) - xpForLevel(départ) - xpGagnée`
5. Countdown = `timeToGain(curGl, Math.min(xpRestante, curGl))`
- Si XP restante ≤ capacité de la jauge : temps pour atteindre la cible
- Sinon : temps de vidange complète (cohérent avec "Vide en")
--- ---
## 10. Countdown global d'un enclos — `enclosGlobalState(enc)` ## 11. Countdown global d'un enclos — `enclosGlobalState(enc)`
**Question** : "Dans combien de temps TOUTES les DD de cet enclos auront atteint TOUTES leurs cibles ?" **Question** : "Dans combien de temps TOUTES les DD de cet enclos auront atteint TOUTES leurs cibles ?"
1. Pour chaque DD × chaque jauge active → appeler `computeGaugeLive` 1. Pour chaque DD × chaque jauge active → appeler `computeGaugeLive`
2. Prendre le **maximum** de tous les countdowns = le plus long à terminer 2. Prendre le **maximum** de tous les countdowns = le plus long à terminer
3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`) 3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`)
4. `allDone = true` si toutes les DD sont terminées 4. `allDone = true` si **toutes les jauges actives** de **toutes les DDs** ont atteint leur cible
**Règle d'alarme unique** : la session se termine (`complete-timer`) une seule fois, au timer le plus long. Si un enclos a baffeur + mangeoire, l'alarme ne sonne que quand les DEUX cibles (sérenité ET niveau XP) sont atteintes pour toutes les DDs. Il n'y a pas d'alarme intermédiaire quand une seule cible est atteinte.
**Prévisualisation avant démarrage** : la fonction calcule même quand le timer n'est pas démarré (`started = false`, `el = 0`). L'"Alarme dans" et les timers DD se mettent à jour en temps réel dès que le joueur saisit une valeur de jauge ou une cible — sans avoir à démarrer le timer.
## 11 bis. Vérification globale de complétion — `checkAllEnclosCompletion()` dans `App`
La vérification de complétion ne dépend pas de la vue active ni du focus de la fenêtre.
**Pourquoi pas dans la boucle rAF ?** `requestAnimationFrame` se **suspend** quand l'application Electron perd le focus OS (ex : l'utilisateur alt-tab vers une autre application). Si la détection était dans la boucle rAF, la notification ne sonnerait jamais quand l'utilisateur est hors de l'app.
**Solution** : `checkAllEnclosCompletion()` tourne dans un `window.setInterval(..., 1000)` indépendant, démarré dans `App.render()` et nettoyé dans `App.destroy()`. `setInterval` continue de s'exécuter même quand la fenêtre est en arrière-plan.
**Algorithme** :
1. Parcourt **tous** les enclos en cours (`summary.running = true`)
2. Pour chacun, récupère l'état complet et appelle `enclosGlobalState`
3. Si `allDone = true`, exécute `complete-timer` → alarme + notification immédiate
**Double protection** : `EnclosView.update()` appelle aussi `complete-timer` quand `allDone && running` (quand l'utilisateur est sur l'enclos). Le handler `complete-timer` possède un guard `if (!enc.timer.running || enc.alerted['__done__']) return` pour éviter un double déclenchement.
--- ---
## 11. Arbre de réapprovisionnement — `calcAppro()` ## 12. Recharge de jauge en cours de session
Le joueur peut **recharger une jauge pendant que le timer tourne** (ex : remplir la mangeoire à mi-session).
### Enregistrement (`recharge-gauge`)
- Stocke `{ atSec: elapsed(), level: newLevel }` dans `timer.gaugeRecharges[gid]`
- Plusieurs recharges s'accumulent dans un tableau
- Le `reset-timer` vide tous les tableaux de recharge
### Impact sur le calcul
`computeGaugeState` segmente automatiquement le calcul entre chaque recharge :
- Segment 1 depuis `startGl` jusqu'à la première recharge
- Segment 2 depuis le nouveau niveau jusqu'à la recharge suivante (ou la fin)
- etc.
Le gel au cap absolu est vérifié dans chaque segment : si la stat atteint son cap avant la prochaine recharge, le gel est précis à la seconde près.
### Affichage
Les inputs de jauge affichent une bordure verte (classe `.gauge-inp-recharge`) pendant que le timer tourne, indiquant que toute nouvelle saisie sera interprétée comme une recharge.
---
## 13. Mises à jour en temps réel des inputs
Tous les champs de saisie (niveaux de jauges, stats des DD, cibles) déclenchent une mise à jour de l'état à **chaque frappe** via un listener `input`, en plus du `blur` final. Cela permet :
- L'"Alarme dans" de se recalculer instantanément pendant la saisie
- Les timers DD (XP, sérénité, etc.) de se mettre à jour à la volée
- Une expérience cohérente avant ET pendant le timer
Exception : les recharges de jauge ne sont déclenchées que sur `blur`/`Enter` (pas sur chaque frappe) pour éviter d'enregistrer des recharges partielles.
---
## 14. Arbre de réapprovisionnement — `calcAppro()`
**Question** : "Pour produire Q exemplaires de la race X, de quelles races et en quelles quantités ai-je besoin ?" **Question** : "Pour produire Q exemplaires de la race X, de quelles races et en quelles quantités ai-je besoin ?"
@ -177,11 +302,11 @@ Chaque race de génération ≥ 2 est produite par le croisement de 2 races pare
1. On part de la race cible et de la quantité voulue 1. On part de la race cible et de la quantité voulue
2. Pour chaque génération (de la plus haute à gen 2) : 2. Pour chaque génération (de la plus haute à gen 2) :
- Pour chaque race nécessaire à cette génération : - Pour chaque race nécessaire à cette génération :
- Calculer le nombre de couples nécessaires (voir §11.1) - Calculer le nombre de couples nécessaires (voir §14.1)
- Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool - Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool
3. Les races gen 1 restantes = matières premières 3. Les races gen 1 restantes = matières premières
### 11.1 Mécanisme du reproducteur ### 14.1 Mécanisme du reproducteur
Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés. Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés.
@ -192,49 +317,33 @@ Sinon → couples = Q - R
`R` = nombre de reproducteurs, `Q` = quantité nécessaire. `R` = nombre de reproducteurs, `Q` = quantité nécessaire.
**Explication** :
- Si on a assez de reproducteurs (≥ moitié de Q), chaque reproducteur fait 2 bébés → on divise par 2
- Sinon, chaque reproducteur fait sa part et les autres sont des "one-shot"
--- ---
## 12. Calcul d'inventaire avec contraintes ♂/♀ — `calcInventaire()` ## 15. Calcul d'inventaire avec contraintes ♂/♀ — `calcInventaire()`
**Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?" **Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?"
### Modèle de données ### Modèle de données
Chaque race dans l'inventaire : `{ m: mâles, f: femelles, n: neutres }` Chaque race dans l'inventaire : `{ m: mâles, f: femelles, n: neutres }`
- `m` et `f` = stock réel renseigné par le joueur
- `n` = bébés produits par le calcul (sexe inconnu)
### Contrainte fondamentale
Un croisement nécessite **1 mâle + 1 femelle**. On ne peut pas croiser ♂+♂ ou ♀+♀.
### Algorithme : round-robin par génération ### Algorithme : round-robin par génération
Pour chaque génération (2 → 10) : Pour chaque génération (2 → 10) :
1. Lister tous les croisements possibles à cette génération 1. Lister tous les croisements possibles à cette génération
2. **Boucle round-robin** : tant qu'au moins un croisement est possible : 2. **Boucle round-robin** : tant qu'au moins un croisement est possible :
- Pour chaque croisement [Parent A + Parent B → Bébé] : - Vérifier qu'on a 1 mâle-capable chez A ET 1 femelle-capable chez B (ou l'inverse)
- Vérifier qu'on a 1 mâle-capable chez A ET 1 femelle-capable chez B (ou l'inverse) - Consommer les parents (priorité au stock réel m/f, puis les neutres n)
- Si même race (A = B) : vérifier qu'on a ≥ 2 individus dont au moins 1 ♂ et 1 ♀ - Ajouter 1 neutre (`n++`) à la race du bébé produit
- Consommer les parents (priorité au stock réel m/f, puis les neutres n)
- Ajouter 1 neutre (`n++`) à la race du bébé produit
3. Les bébés produits (neutres) sont disponibles pour les générations suivantes
### Priorité de consommation ### Priorité de consommation
``` ```
takeMale : si m > 0 → m--, sinon n-- takeMale : si m > 0 → m--, sinon n--
takeFemale : si f > 0 → f--, sinon n-- takeFemale : si f > 0 → f--, sinon n--
``` ```
Les neutres (`n`) peuvent jouer le rôle de ♂ ou ♀ selon le besoin.
### Pourquoi round-robin ?
Pour **répartir équitablement** les ressources entre les croisements possibles, plutôt que de tout donner au premier croisement trouvé.
--- ---
## 13. Constantes des stats ## 16. Constantes des stats
| Stat | Min | Max | Jauge(s) associée(s) | | Stat | Min | Max | Jauge(s) associée(s) |
|-----------|--------|--------|----------------------| |-----------|--------|--------|----------------------|
@ -246,10 +355,115 @@ Pour **répartir équitablement** les ressources entre les croisements possibles
--- ---
## 14. Système de snapshots ## 17. Système de snapshots
Quand le timer démarre, on prend un **snapshot** de l'état : Quand le timer démarre (démarrage initial uniquement, pas lors d'une reprise de pause) :
- `snapGauges` : niveaux de toutes les jauges au moment du démarrage - `snapGauges` : niveaux de toutes les jauges actives au moment du démarrage
- `snapStats[dd.id]` : stats de chaque DD au moment du démarrage - `snapStats[dd.id]` : stats de chaque DD au moment du démarrage
- `gaugeRecharges` : réinitialisé à `{}`
Tous les calculs utilisent ces snapshots comme point de départ, pas l'état en temps réel. Cela évite les dérives de calcul si les valeurs sont modifiées pendant que le timer tourne. Tous les calculs utilisent ces snapshots comme point de départ. Une **reprise de pause** accumule uniquement `pausedMs` sans toucher aux snapshots.
---
## 18. Flux de session — enchaînement des sessions
### Session unique (sans jauges supplémentaires)
1. Timer démarré → `start-timer` prend les snapshots
2. Toutes les cibles atteintes → `complete-timer` déclenché
3. Session terminée : timer figé, bannière "Session terminée" visible, jauges continuent en fond
4. Clic "🔄 Nouvelle fournée" → `nouvelle-fournee` : reset complet + 1 DD neuve
### Session enchaînée (nouvelles stats à monter)
1. Session terminée (`alerted['__done__'] = true`)
2. Les **boutons de jauges sont déverrouillés** → l'utilisateur sélectionne de nouvelles jauges
3. L'utilisateur configure les niveaux de jauges pour la nouvelle session
4. Clic "▶ Démarrer" → `start-timer` détecte `alerted['__done__']`**démarrage initial** (pas reprise de pause) → nouveaux snapshots pris avec les stats actuelles des DD
5. Nouvelle session démarre : les stats des DD reflètent les gains de la session précédente
**Règle de déverrouillage des jauges** : `locked = started && !enc.alerted['__done__']`
Les jauges sont verrouillées uniquement pendant une session active (running ou en pause manuelle). Elles sont déverrouillées une fois la session terminée automatiquement.
**Bouton timer** : affiche "▶ Reprendre" uniquement en cas de pause manuelle (`pausedAt` et `!alerted['__done__']`). Après complétion automatique, affiche "▶ Démarrer" (nouvelle session).
---
## 19 bis. Commande `nouvelle-fournee`
Remet l'enclos dans un état "vierge" pour une nouvelle fournée complète :
- Reset timer (efface startTime, snapshots, alerted, recharges)
- Remet tous les niveaux de jauges à 0
- Supprime toutes les DDs
- Ajoute 1 nouvelle DD avec les stats de base (`serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1`)
Distinct de `reset-timer` (qui remet seulement le timer à zéro, conserve les DDs et les jauges) et de `clear-enclos` (qui remet tout à zéro incluant les jauges actives et le nom).
---
## 18. Cycle de vie de `complete-timer`
La commande `complete-timer` représente la **fin naturelle d'une session** (toutes les cibles atteintes). Elle est distincte d'un simple `stop-timer` (pause manuelle).
### Ce que fait `complete-timer`
1. **Guard** : si `enc.timer.running = false` ou si `enc.alerted['__done__']` est déjà posé → retourne immédiatement (idempotent, pas de double alarme)
2. Pose `enc.timer.running = false`
3. Pose `enc.timer.pausedAt = Date.now()` — gèle `elapsed()` à l'instant de complétion
4. Pose `enc.alerted['__done__'] = true` — active le mode "continuation en fond" dans `elapsedLive`
5. Persiste l'état via `repo.save()`
6. Émet l'événement `timer-completed` → déclenche alarme audio + notification Windows/mobile
### Différence avec `stop-timer` (pause manuelle)
- `stop-timer` : pose `running = false` et `pausedAt` mais **ne pose pas** `alerted['__done__']`
- Conséquence : `elapsedLive` retourne `elapsed()` figé → toutes les jauges se figent sur la pause
- `complete-timer` : `alerted['__done__'] = true``elapsedLive` continue en temps réel → jauges continuent de se vider en fond
### Après `complete-timer`
| Élément | Comportement |
|---------|-------------|
| Affichage "Temps écoulé" (enclos + dashboard) | **Figé** — utilise `elapsed(timer)` = `(pausedAt - startTime - pausedMs) / 1000` |
| Jauges (niveau, barre) | **Continuent**`enclosGaugeCurGl` utilise `elapsedLive` |
| Stats estimées des DD | **Continuent**`computeGaugeLive` utilise `elapsedLive` |
| Countdown "Alarme dans" | Affiche `✅` |
| Bannière "Session terminée" | Visible dans la vue enclos |
---
## 19. Animations et détection de tick — `DragodindeCard`
### Détection de tick
Un "tick" survient toutes les 10 secondes. La carte DD détecte un nouveau tick en comparant `Math.floor(elapsedLive(enc) / 10)` avec la valeur précédente.
Utiliser `elapsedLive(enc)` (et non `elapsed`) garantit que les animations de tick **continuent** après la fin de session, le temps que toutes les jauges se vident en arrière-plan.
### `deltaActive` — quand afficher l'animation de delta
L'animation de delta ("+20 xp", "-10 sérenité"…) s'affiche à chaque tick **uniquement si la jauge est encore active**. Une jauge est considérée inactive quand la stat atteint son **cap absolu** (pas sa cible) :
```
Pour les jauges de stats : atAbsCap = (estStat >= statMax) ou (estStat <= statMin)
Pour la mangeoire (XP) : atAbsCap = (estLevel >= 200)
```
Cela signifie que l'animation continue après la cible de l'utilisateur (ex: sérénité cible -60 → animation continue jusqu'à -5000).
### Badge "✓ TERMINÉ"
Affiché sur la carte DD quand **toutes les jauges actives** ont `done = true` pour cette DD. Les jauges de stats et la mangeoire comptent toutes de la même façon — pas de distinction.
---
## 20. Événements domaine actifs
| Événement | Déclencheur | Effet |
|-----------|-------------|-------|
| `timer-completed` | `complete-timer` (toutes cibles atteintes) | Alarme audio + notification Windows + notification mobile ntfy |
| `gauge-threshold-reached` | Franchissement d'un palier de jauge | (usage interne) |
| `accouplement-registered` | Enregistrement d'un accouplement | Mise à jour des stats globales |
| `enclos-deleted` | Suppression d'un enclos | Nettoyage de l'état |
**Événements supprimés** (ancienne implémentation, ne plus utiliser) :
- `target-reached` — ancienne alarme intermédiaire quand une cible de stat était atteinte avant les autres
- `xp-target-reached` — ancienne alarme séparée pour la cible XP
Ces événements ont été remplacés par la règle d'alarme unique : **une seule alarme au timer le plus long**, déclenchée par `timer-completed` uniquement.

View File

@ -0,0 +1,335 @@
# Fonctionnalités par écran — Minuteur Dragodinde
---
## 1. Shell principal (App)
### Header
- Bouton hamburger (☰) pour ouvrir/fermer la sidebar
- Logo "⚔ MINUTEUR DRAGODINDE"
- Sous-titre "Dofus 3 · Gestion multi-enclos en temps réel · vX.X.X"
- Version injectée dynamiquement via Electron API
- Badge "DEV" en mode développement
### Barre d'onglets
- Onglet Dashboard (📊)
- Un onglet par enclos :
- Icône 🐉 + nom de l'enclos
- Dot animé : vert pulsant si timer en cours, gris si inactif
- Bouton supprimer (✕), visible si plus d'1 enclos
- Drag-and-drop pour réordonner les onglets
- Bouton "+ Enclos" (désactivé si max atteint)
### Bannière de mise à jour
- États : disponible → téléchargement (barre de progression %) → prête → erreur
- Bouton "Installer et redémarrer"
- Bouton fermer (✕) sur erreur
### Zoom clavier
- Ctrl + (+) : zoom avant
- Ctrl + (-) : zoom arrière
- Ctrl + 0 : reset 100%
---
## 2. Sidebar — Navigation latérale
- **Dashboard** (📊)
- **Section "Enclos"** : liste dynamique de chaque enclos
- Icône 🐉 + nom + dot coloré (vert=running, gris=idle)
- **Section "Outils"** :
- Accouplement (💑)
- Réappro (🧬)
- Inventaire (📦)
- Workflows (📋)
- **Paramètres** (⚙)
- Indicateur visuel de la vue active
- Fermeture auto au clic sur l'overlay
---
## 3. Dashboard — Vue d'ensemble
### Cartes des enclos
Pour chaque enclos :
- Nom de l'enclos
- Dot animé + statut textuel ("En cours" / "Pause" / "Inactif")
- Liste des jauges actives (badges colorés avec icônes)
- Nombre de dragodindes + terminées (ex: "5 DD · 3/5 ✅")
- Temps restant (countdown live) ou "✅ Terminé !"
- Temps écoulé (live)
- Bouton "→ Gérer cet enclos" (navigation)
### Statistiques globales
- **KPI** :
- Bébés au total
- Dragodindes actives
- Couples accouplés
- Taux de réussite (%)
- Races obtenues
- **Histogramme des races** : barres horizontales triées par quantité, colorées par race
- **Bouton "Réinitialiser"** : efface stats et accouplements
### Temps réel
- Countdowns et temps écoulés mis à jour en continu
- Dots animés sur les enclos en cours
---
## 4. Enclos — Vue timer principale
### En-tête
- **Nom de l'enclos** : input éditable (focus vide, blur sauvegarde, Escape annule, Enter valide)
- **Bouton "🗑 Vider l'enclos"** : supprime toutes les DD et reset
### Carte Jauges (max 2 actives)
- **6 boutons toggle** : Baffeur (), Caresseur (), Foudroyeur (⚡), Abreuvoir (💧), Dragofesse (❤), Mangeoire (🍖)
- État actif : bordure colorée
- Verrouillé si timer en cours
- **Exclusion mutuelle** baffeur ↔ caresseur (l'un bloque l'autre)
- **Par jauge active** :
- Icône + nom + badge Tier ("Tier X · ±Y/tick")
- Input niveau (0100 000, step 1000)
- Avant timer : modifie le niveau
- Pendant timer : recharge la jauge (style visuel distinct)
- Barre de progression (% de remplissage)
- Info : "Jauge : {niveau} · Vide en {durée}"
### Barre timer
- **Bouton** : "▶ Démarrer" / "⏸ Pause" / "▶ Reprendre"
- **Temps écoulé** (gauche) : HH:MM:SS live
- **Alarme dans** (droite) : countdown global ou "✅" ou "--:--:--"
### Bannière de fin
- "✅ Session terminée !" (visible quand toutes les cibles atteintes)
- Bouton "🔄 Nouvelle fournée" (reset timer, conserve les niveaux de jauges)
### Section Dragodindes
- Compteur "X/10"
- Bouton " Ajouter une Dragodinde" (désactivé si 10 atteint)
- Grille de cartes DD (drag-and-drop pour réordonner)
---
## 5. Carte Dragodinde (sous-composant d'Enclos)
### En-tête
- Poignée de drag (⠿)
- Nom éditable (même comportement focus/blur)
- Bouton supprimer (✕)
### Pilules de stats (5)
| Stat | Icône | Plage | Couleur |
|------|-------|-------|---------|
| Sérénité | 😊 | -5000 à +5000 | Violet |
| Endurance | ⚡ | 0 à 20 000 | Jaune |
| Maturité | 💧 | 0 à 20 000 | Cyan |
| Amour | ❤️ | 0 à 20 000 | Rouge |
| XP (niveau) | ⭐ | 1 à 200 | Orange |
- Input éditable par pilule (focus/blur)
- Mise à jour live pendant le timer
- Effet lumineux (glow) quand la stat atteint un cap
- Animation delta (+X / -X) qui pop à chaque tick de 10s
### Cible sérénité (😊)
- Input numérique (bornes selon jauge active : négatif si baffeur, positif si caresseur)
- Bouton effacer (✕)
- ETA affiché : "~Xm XXs" ou "✅" ou "/" (si jauge nécessaire non active)
- Placeholder dynamique ("-5000 à 0" ou "0 à 5000")
### Cible niveau (⭐)
- Input numérique (1200)
- Bouton effacer (✕)
- ETA affiché
### Blocs jauges actives
**Pour chaque jauge active :**
**Mangeoire (XP) — bloc spécial :**
- En-tête : icône + "XP / NIVEAU"
- ETA niveau 200 : "→ NIV. 200 : ~Xj Xh"
- Barre de progression vers 200 + pourcentage
- Valeur live (NIV. X), delta (+X xp), countdown
**Autres jauges :**
- En-tête : icône + nom (coloré)
- Valeur live, delta (pop animé), countdown
- Barre de progression vers la cible
### Badge "✓ TERMINÉ"
- Affiché quand toutes les jauges actives de cette DD ont atteint leur cible
---
## 6. Accouplement — Enregistrement de croisements
### Étape 1 : Sélection du parent 1
- Barre de recherche (🔍) avec bouton effacer
- Filtres par génération (onglets colorés : Toutes, Gen 110)
- Grille de cartes race (image, nom, badge gen)
- Message vide si aucun résultat
### Étape 2 : Sélection du parent 2
- Bouton "← Retour"
- Carte du parent 1 affichée (non cliquable)
- Label "Choisir le partenaire :"
- Grille des partenaires compatibles uniquement
- Chaque carte montre "→ {bébé}" en dessous
### Étape 3 : Résultat
- Bouton "← Retour"
- Affichage en ligne : Parent 1 + Parent 2 = Bébé (cartes avec images et badges gen)
- Input "Couples" (nombre)
- Input "Bébés obtenus" (nombre)
- Bouton "Enregistrer" → commande `register-accouplement`
---
## 7. Réapprovisionnement — Calcul de plan d'élevage
### Étape 1 : Sélection de la race cible
- Barre de recherche (🔍) avec bouton effacer
- Filtres par génération (Gen 210 uniquement)
- Grille de cartes race (gen 2+ seulement)
### Étape 2 : Plan d'élevage
- Bouton "← Retour"
- Carte cible + input quantité (mise à jour live du plan)
**Matériaux (Gen 1) :**
- Mini-cartes par race de base : image, nom, badge gen, ♂X ♀Y
**Étapes (Gen 2+) :**
Par génération, pour chaque croisement :
- En-tête "Étape X — Gen Y" (coloré)
- Affichage : Parent A + Parent B → Bébé (mini-cartes)
- Input "Reproducteurs" (nombre, par croisement)
- Bouton inversion ♂/♀ (swap la direction de reproduction)
**Actions :**
- Bouton "Sauvegarder le workflow"
---
## 8. Inventaire — Gestion du stock & simulation
### Saisie du stock
- Résumé : "X DD en stock (Y races)"
- Bouton "Réinitialiser" (remet tout à 0)
- Bouton "Calculer les bébés" (lance la simulation)
- Barre de recherche (🔍)
- Filtres par génération (Gen 110)
- Grille de cartes race :
- Image, nom, badge gen
- Input ♂ (mâles) + input ♀ (femelles)
- Mise à jour du résumé en temps réel pendant la saisie
- Persistance automatique du stock entre les changements d'écran
### Résultats de simulation
- En-tête global : "{N} bébés possibles sur {G} génération(s)"
- Bouton "Sauvegarder le workflow"
- Par génération :
- Badge gen + total bébés
- Par croisement : Parent A (♂X ♀Y) + Parent B (♂X ♀Y) → Bébé (×Z)
- Section "DD restantes (non utilisées)" : mini-cartes du stock inutilisé
### Algorithme
- Allocation proportionnelle (pas glouton) : chaque parent est réparti équitablement entre les croisements qui l'utilisent
- Cascade multi-générations (Gen 2 → Gen 10)
---
## 9. Workflows — Suivi des plans d'élevage
### Vue liste
- Titre "Plans d'élevage"
- Barre de recherche (🔍)
- Filtres par génération (uniquement les gens qui ont des workflows)
- Message vide si aucun workflow
**Par carte workflow :**
- Image de la race cible + badge gen
- Nom du workflow (tronqué)
- "Cible × Quantité — Date de création"
- Barre de progression globale (colorée) + "X/Y — Z%"
- Bouton supprimer (✕)
- Clic → vue détail
### Vue détail
- Bouton "← Retour"
- **Carte d'en-tête** :
- Image + badge gen
- Nom, cible × quantité, date
- Barre de progression globale + pourcentage
**Section Matériaux de base (si présents) :**
- Par matériau :
- Nom de la race (coloré)
- Mini barre de progression
- Input de progression (nombre)
- Label "X / Y"
**Sections par génération :**
- En-tête "Génération X" (coloré)
- Par croisement :
- Nom race (coloré)
- "ParentA × ParentB — X couples, repro ×Y"
- Input de progression (nombre)
- Label "X / Y"
- Mini barre de progression
### Temps réel
- La barre de progression globale se met à jour pendant la saisie
- Les barres individuelles se mettent à jour en direct
---
## 10. Paramètres
### Carte 🔊 Son d'alarme
- Select : Arpège, Pulsation, Fanfare, Cloche
- Bouton ▶ (test du son sélectionné, joue directement via WebAudio)
### Carte 🔔 Notifications
- **Bouton Notifs PC** : "🔔 Notifs PC activées" / "🔕 Notifs PC désactivées" (toggle)
- **Bouton Mobile** : "📱 Mobile activé" / "📱 Activer mobile" (ouvre la modal)
### Modal ntfy (notifications mobiles)
**Si non activé :**
- Texte explicatif
- Bouton "🔔 Activer les notifications mobiles" (génère un topic automatique)
**Si activé :**
- ① QR code pour télécharger l'app ntfy (Play Store / App Store)
- ② QR code pour s'abonner automatiquement aux notifications
- Bouton "🔔 Tester" (envoie une notification de test)
- Bouton "✕ Désactiver" (supprime le topic)
- Bouton "Fermer"
---
## Comportements transversaux
### Focus/blur des inputs (tous les écrans)
- Focus : vide le champ, sauvegarde la valeur précédente
- Blur sans valeur : restaure la valeur précédente
- Blur avec valeur : applique et sauvegarde
- Escape : annule et restaure
- Enter : valide (blur)
### Système de timer
- Snapshot des jauges et stats au démarrage
- Calcul par segments (recharges en cours de session)
- Gel au cap absolu de chaque stat
- Alarme unique quand toutes les cibles sont atteintes (son + notification PC + ntfy mobile)
- Temps réel : requestAnimationFrame continu
### Persistance
- Sauvegarde automatique après chaque mutation d'état
- LocalStorage (web) ou fichier JSON (Electron userData)
- Isolation des données DEV / production
### Drag-and-drop
- Onglets enclos : réordonnancement par glisser-déposer
- Cartes dragodindes : réordonnancement dans le même enclos

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,920 @@
# Refonte Graphique — Écran Accouplement
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refondre l'écran d'accouplement pour passer d'un wizard séquentiel à un layout single-page avec panneaux Parent 1 / Centre / Parent 2 + grille de races, style Obsidienne glassmorphism.
**Architecture:** Réécriture complète du HTML généré par `AccouplementView.ts` + remplacement des styles `.accoup-*` dans `components.css` par de nouveaux styles dans `obsidienne.css`. La logique métier (imports Race, CommandBus) reste identique.
**Tech Stack:** TypeScript, CSS (tokens Obsidienne/MD3), Material Symbols Outlined, `getDDImage()` helper existant.
---
### Task 1 : Nouveaux styles CSS Obsidienne pour l'accouplement
**Files:**
- Modify: `src/presentation/styles/obsidienne.css` (ajouter en fin de fichier)
- Modify: `src/presentation/styles/components.css:314-401` (supprimer les anciens styles `.accoup-*`)
**Contexte:** La maquette utilise du glassmorphism (fond semi-transparent + backdrop-blur + bordure purple subtile), des `rounded-2xl` (16px), des chips pill pour les générations, et un layout grid 12 colonnes. On traduit tout ça en CSS vanilla avec les tokens `--md-*` déjà définis dans `variables.css`.
**Step 1 : Supprimer les anciens styles accouplement de components.css**
Supprimer les lignes 314-331 et 401 de `components.css` (tout ce qui commence par `.accoup-` et `.accouplement-view`).
**Step 2 : Ajouter les nouveaux styles dans obsidienne.css**
Ajouter en fin de fichier :
```css
/* ── Accouplement View ── */
.accoup-view {
display: flex;
flex-direction: column;
gap: 24px;
padding: 8px;
}
/* Parent Selection Grid (3 columns: parent1 | center | parent2) */
.accoup-parents {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 24px;
align-items: start;
}
.accoup-parent-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.accoup-parent-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 8px;
}
.accoup-parent-title {
font-family: 'Manrope', sans-serif;
font-size: 0.95rem;
font-weight: 700;
color: var(--md-on-surface);
text-transform: uppercase;
letter-spacing: -0.03em;
}
.accoup-gender-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
.accoup-gender-badge.male {
color: var(--md-secondary);
background: rgba(134, 20, 90, 0.2);
}
.accoup-gender-badge.female {
color: var(--md-primary);
background: rgba(193, 133, 253, 0.2);
}
/* Placeholder card (empty parent slot) */
.accoup-placeholder {
height: 180px;
border-radius: 16px;
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.accoup-placeholder:hover {
background: rgba(255, 255, 255, 0.07);
}
.accoup-placeholder-inner {
text-align: center;
}
.accoup-placeholder-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
transition: transform 0.2s;
}
.accoup-placeholder:hover .accoup-placeholder-icon {
transform: scale(1.1);
}
.accoup-placeholder-icon .material-symbols-outlined {
font-size: 28px;
opacity: 0.4;
}
.accoup-placeholder-text {
font-size: 11px;
color: var(--md-on-surface-variant);
}
/* Selected parent card (replacing placeholder) */
.accoup-selected-parent {
height: 180px;
border-radius: 16px;
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
cursor: pointer;
}
.accoup-selected-parent:hover {
border-color: rgba(168, 85, 247, 0.5);
}
.accoup-selected-parent .race-card-avatar {
width: 64px;
height: 64px;
}
.accoup-selected-parent .race-card-avatar img {
width: 64px;
height: 64px;
}
.accoup-selected-parent-name {
font-size: 13px;
font-weight: 700;
color: var(--md-on-surface);
}
.accoup-selected-parent-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
color: #fff;
}
.accoup-selected-parent-clear {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: none;
color: var(--md-on-surface-variant);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: background 0.2s;
}
.accoup-selected-parent-clear:hover {
background: rgba(255, 110, 132, 0.2);
color: var(--md-error);
}
/* Center pairing column */
.accoup-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 0;
min-width: 160px;
}
.accoup-heart {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--md-primary), var(--md-secondary));
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(203, 151, 255, 0.2);
}
.accoup-heart .material-symbols-outlined {
color: #000;
font-size: 22px;
}
.accoup-center-inputs {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.accoup-center-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.accoup-center-label {
display: block;
font-size: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 700;
color: var(--md-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: center;
}
.accoup-center-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 8px 12px;
text-align: center;
font-size: 14px;
font-weight: 700;
color: var(--md-primary);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.accoup-center-input:focus {
border-color: var(--md-primary);
box-shadow: 0 0 0 1px var(--md-primary);
}
.accoup-center-input.secondary {
color: var(--md-secondary);
}
.accoup-center-input.secondary:focus {
border-color: var(--md-secondary);
box-shadow: 0 0 0 1px var(--md-secondary);
}
.accoup-register-btn {
padding: 10px 24px;
background: linear-gradient(135deg, var(--md-primary), var(--md-primary-container));
color: var(--md-on-primary);
font-family: 'Manrope', sans-serif;
font-weight: 800;
font-size: 11px;
border: none;
border-radius: 9999px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(203, 151, 255, 0.3);
transition: transform 0.1s, box-shadow 0.2s;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.accoup-register-btn:hover {
box-shadow: 0 6px 20px rgba(203, 151, 255, 0.4);
}
.accoup-register-btn:active {
transform: scale(0.98);
}
.accoup-register-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Grid panel (glass) */
.accoup-grid-panel {
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
border-radius: 24px;
padding: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* Gen chips */
.accoup-gen-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding-bottom: 20px;
margin-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.accoup-gen-chips-label {
font-size: 10px;
font-weight: 700;
color: var(--md-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-right: 12px;
}
.accoup-gen-chip {
padding: 6px 16px;
border-radius: 9999px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
border: none;
background: rgba(255, 255, 255, 0.05);
color: var(--md-on-surface-variant);
transition: background 0.2s, color 0.2s;
}
.accoup-gen-chip:hover {
color: var(--md-on-surface);
background: rgba(255, 255, 255, 0.1);
}
.accoup-gen-chip.active {
background: var(--md-primary);
color: var(--md-on-primary);
}
/* Search (kept from existing, restyled) */
.accoup-search-wrap {
position: relative;
display: flex;
align-items: center;
margin-bottom: 20px;
}
.accoup-search {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--md-on-surface);
padding: 10px 40px 10px 14px;
font-size: 13px;
font-weight: 600;
outline: none;
transition: border-color 0.2s;
}
.accoup-search:focus {
border-color: var(--md-primary);
}
.accoup-search-clear {
position: absolute;
right: 10px;
background: none;
border: none;
color: var(--md-on-surface-variant);
cursor: pointer;
font-size: 16px;
padding: 4px;
transition: color 0.15s;
}
.accoup-search-clear:hover {
color: var(--md-on-surface);
}
/* Dragon cards grid */
.accoup-race-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.accoup-race-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
text-align: center;
}
.accoup-race-card:hover {
border-color: rgba(203, 151, 255, 0.4);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
}
.accoup-race-card.selecting {
border-color: rgba(203, 151, 255, 0.15);
}
.accoup-race-card.selecting:hover {
border-color: rgba(203, 151, 255, 0.5);
}
.accoup-race-card-img {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-bottom: 8px;
}
.accoup-race-card-img .race-card-avatar {
width: 56px;
height: 56px;
transition: transform 0.2s;
}
.accoup-race-card:hover .race-card-avatar {
transform: scale(1.1);
}
.accoup-race-card-img .race-card-avatar img {
width: 56px;
height: 56px;
}
.accoup-race-card-gen {
position: absolute;
top: 4px;
right: 4px;
padding: 2px 6px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
font-size: 8px;
font-weight: 700;
color: var(--md-tertiary, #ffe083);
}
.accoup-race-card-name {
font-size: 11px;
font-weight: 700;
color: var(--md-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accoup-race-card-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
}
.accoup-race-card-sub {
font-size: 9px;
color: var(--md-on-surface-variant);
}
/* Empty state */
.accoup-empty {
text-align: center;
color: var(--md-on-surface-variant);
padding: 40px;
font-style: italic;
font-size: 13px;
}
```
**Step 3 : Vérifier le build**
Run: `npm run build` (depuis Windows)
Expected: Build réussi sans erreur CSS.
**Step 4 : Commit**
```bash
git add src/presentation/styles/obsidienne.css src/presentation/styles/components.css
git commit -m "refactor(accouplement): remplace anciens styles par design Obsidienne glassmorphism"
```
---
### Task 2 : Réécriture du HTML — layout single-page avec panneaux parents + grille
**Files:**
- Modify: `src/presentation/components/AccouplementView.ts` (réécriture complète du rendering)
**Contexte:** On remplace le wizard 3 étapes par un layout unique :
- **Haut** : grille 3 colonnes (Parent 1 | Centre cœur+inputs+register | Parent 2)
- **Bas** : panneau glass avec chips génération + recherche + grille de races
- Quand on clique une race, elle remplit le slot parent approprié (P1 si vide, sinon P2)
- Quand les 2 parents sont sélectionnés, le bouton Enregistrer devient actif
**Step 1 : Réécrire le state et la structure**
Remplacer l'interface `AccoupState` et toute la classe :
```typescript
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_BY_PARENTS, COMPATIBLE_PARTNERS, raceColor } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
interface AccoupState {
parent1: string | null;
parent2: string | null;
filterGen: number | null;
search: string;
couples: string;
babies: string;
selectingSlot: 1 | 2;
}
export class AccouplementView {
private el: HTMLElement | null = null;
private accoupState: AccoupState = {
parent1: null, parent2: null,
filterGen: null, search: '',
couples: '', babies: '',
selectingSlot: 1,
};
private dirty = true;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'accoup-view';
container.appendChild(this.el);
this.dirty = true;
this.update();
}
update(): void {
if (!this.el || !this.dirty) return;
this.dirty = false;
this.renderSinglePage();
}
destroy(): void {
this.el?.remove();
this.el = null;
}
/* ── All races list (cached) ── */
private getAllRaces(): { name: string; gen: number }[] {
const all: { name: string; gen: number }[] = [];
for (const base of ['Rousse', 'Amande', 'Dorée'])
all.push({ name: base, gen: 1 });
for (const [g, rs] of Object.entries(RACES_DATA))
for (const r of rs)
all.push({ name: r.name, gen: parseInt(g) });
return all;
}
/* ── Filtered races based on gen filter, search, and partner compatibility ── */
private getFilteredRaces(): { name: string; gen: number }[] {
const { filterGen, search, parent1, selectingSlot } = this.accoupState;
let races = this.getAllRaces();
// If selecting parent 2, only show compatible partners
if (selectingSlot === 2 && parent1) {
const partners = COMPATIBLE_PARTNERS[parent1] ?? [];
const partnerNames = new Set(partners.map(p => p.partner));
races = races.filter(r => partnerNames.has(r.name));
}
const q = search.trim().toLowerCase();
return races.filter(r =>
(filterGen ? r.gen === filterGen : true) &&
(q ? r.name.toLowerCase().includes(q) : true)
);
}
/* ── Baby name from parents ── */
private getBabyName(): string | null {
const { parent1, parent2 } = this.accoupState;
if (!parent1 || !parent2) return null;
return BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? null;
}
/* ── Single page render ── */
private renderSinglePage(): void {
if (!this.el) return;
const { parent1, parent2, filterGen, search, couples, babies, selectingSlot } = this.accoupState;
const baby = this.getBabyName();
const hasBoth = !!(parent1 && parent2 && baby);
let html = '';
/* ── Parent panels row ── */
html += `<div class="accoup-parents">`;
// Parent 1 section
html += `<section class="accoup-parent-section">`;
html += `<div class="accoup-parent-header">
<span class="accoup-parent-title">Parent 1</span>
<span class="accoup-gender-badge male">MÂLE REQUIS</span>
</div>`;
if (parent1) {
const gen1 = RACE_GEN[parent1] ?? 1;
html += `<div class="accoup-selected-parent" data-clear="1">
${getDDImage(parent1)}
<span class="accoup-selected-parent-name">${esc(parent1)}</span>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen1] ?? '#888'}">Gen ${gen1}</span>
<button class="accoup-selected-parent-clear" data-clear="1" title="Retirer">
<span class="material-symbols-outlined" style="font-size:14px">close</span>
</button>
</div>`;
} else {
html += `<div class="accoup-placeholder" data-select-slot="1">
<div class="accoup-placeholder-inner">
<div class="accoup-placeholder-icon">
<span class="material-symbols-outlined" style="color:var(--md-primary)">add</span>
</div>
<p class="accoup-placeholder-text">Cliquer pour choisir un mâle</p>
</div>
</div>`;
}
html += `</section>`;
// Center column
html += `<div class="accoup-center">`;
html += `<div class="accoup-heart">
<span class="material-symbols-outlined mso-fill">favorite</span>
</div>`;
html += `<div class="accoup-center-inputs">
<div class="accoup-center-field">
<label class="accoup-center-label">Nombre de couples</label>
<input class="accoup-center-input" id="accoup-couples" type="number" min="1" value="${esc(couples)}" placeholder="1">
</div>
<div class="accoup-center-field">
<label class="accoup-center-label">Bébés obtenus</label>
<input class="accoup-center-input secondary" id="accoup-babies" type="number" min="0" value="${esc(babies)}" placeholder="0">
</div>
</div>`;
// Baby preview (if both parents selected)
if (hasBoth && baby) {
const babyGen = RACE_GEN[baby] ?? 0;
html += `<div style="text-align:center;margin-top:4px">
<div style="font-size:10px;color:var(--md-on-surface-variant);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:6px">Résultat</div>
${getDDImage(baby)}
<div style="font-size:12px;font-weight:700;color:var(--md-on-surface);margin-top:4px">${esc(baby)}</div>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[babyGen] ?? '#888'};font-size:8px">Gen ${babyGen}</span>
</div>`;
}
html += `<button class="accoup-register-btn" id="accoup-register" ${hasBoth ? '' : 'disabled'}>ENREGISTRER</button>`;
html += `</div>`;
// Parent 2 section
html += `<section class="accoup-parent-section">`;
html += `<div class="accoup-parent-header">
<span class="accoup-parent-title">Parent 2</span>
<span class="accoup-gender-badge female">FEMELLE REQUISE</span>
</div>`;
if (parent2) {
const gen2 = RACE_GEN[parent2] ?? 1;
html += `<div class="accoup-selected-parent" data-clear="2">
${getDDImage(parent2)}
<span class="accoup-selected-parent-name">${esc(parent2)}</span>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen2] ?? '#888'}">Gen ${gen2}</span>
<button class="accoup-selected-parent-clear" data-clear="2" title="Retirer">
<span class="material-symbols-outlined" style="font-size:14px">close</span>
</button>
</div>`;
} else {
html += `<div class="accoup-placeholder" data-select-slot="2">
<div class="accoup-placeholder-inner">
<div class="accoup-placeholder-icon">
<span class="material-symbols-outlined" style="color:var(--md-secondary)">add</span>
</div>
<p class="accoup-placeholder-text">Cliquer pour choisir une femelle</p>
</div>
</div>`;
}
html += `</section>`;
html += `</div>`; // .accoup-parents
/* ── Grid panel ── */
html += `<div class="accoup-grid-panel">`;
// Gen chips
html += `<div class="accoup-gen-chips">`;
html += `<span class="accoup-gen-chips-label">Générations</span>`;
html += `<button class="accoup-gen-chip${filterGen === null ? ' active' : ''}" data-gen="all">Toutes</button>`;
for (let g = 1; g <= 10; g++) {
html += `<button class="accoup-gen-chip${filterGen === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div>`;
// Search
html += `<div class="accoup-search-wrap">
<input class="accoup-search" id="accoup-search-input" type="text"
placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>`;
// Race grid
const filtered = this.getFilteredRaces();
if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
} else {
const selecting = (!parent1 || !parent2);
html += `<div class="accoup-race-grid">`;
for (const race of filtered) {
const genCol = GEN_COLORS[race.gen] ?? '#888';
html += `<div class="accoup-race-card${selecting ? ' selecting' : ''}" data-race="${esc(race.name)}">
<div class="accoup-race-card-img">
${getDDImage(race.name)}
<div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
</div>
<div class="accoup-race-card-name">${esc(race.name)}</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`; // .accoup-grid-panel
this.el.innerHTML = html;
this.bindEvents();
// Restore search focus if active
if (search) {
const inp = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
/* ── Event binding ── */
private bindEvents(): void {
if (!this.el) return;
// Gen chips
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
btn.addEventListener('click', () => {
const val = (btn as HTMLElement).dataset.gen;
this.accoupState.filterGen = val === 'all' ? null : parseInt(val!);
this.dirty = true; this.update();
});
});
// Search
const searchInput = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.accoupState.search = searchInput.value;
this.dirty = true; this.update();
});
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
this.accoupState.search = '';
this.dirty = true; this.update();
});
}
// Placeholder clicks (select slot)
this.el.querySelectorAll<HTMLElement>('[data-select-slot]').forEach(ph => {
ph.addEventListener('click', () => {
this.accoupState.selectingSlot = parseInt(ph.dataset.selectSlot!) as 1 | 2;
// No re-render needed, just changes which slot gets filled
});
});
// Clear parent buttons
this.el.querySelectorAll<HTMLElement>('[data-clear]').forEach(btn => {
if (btn.tagName === 'BUTTON') {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const slot = parseInt(btn.dataset.clear!);
if (slot === 1) {
this.accoupState.parent1 = null;
this.accoupState.parent2 = null; // Reset P2 too since partners depend on P1
this.accoupState.selectingSlot = 1;
} else {
this.accoupState.parent2 = null;
this.accoupState.selectingSlot = 2;
}
this.dirty = true; this.update();
});
}
});
// Race card clicks
this.el.querySelectorAll<HTMLElement>('.accoup-race-card').forEach(card => {
card.addEventListener('click', () => {
const race = card.dataset.race!;
if (!this.accoupState.parent1) {
this.accoupState.parent1 = race;
this.accoupState.selectingSlot = 2;
this.accoupState.filterGen = null;
this.accoupState.search = '';
} else if (!this.accoupState.parent2) {
this.accoupState.parent2 = race;
}
this.dirty = true; this.update();
});
});
// Couples / babies inputs
const couplesInput = this.el.querySelector<HTMLInputElement>('#accoup-couples');
const babiesInput = this.el.querySelector<HTMLInputElement>('#accoup-babies');
if (couplesInput) {
let prev = couplesInput.value;
couplesInput.addEventListener('focus', () => { prev = couplesInput.value; couplesInput.value = ''; });
couplesInput.addEventListener('blur', () => {
if (couplesInput.value === '') couplesInput.value = prev;
this.accoupState.couples = couplesInput.value;
});
}
if (babiesInput) {
let prev = babiesInput.value;
babiesInput.addEventListener('focus', () => { prev = babiesInput.value; babiesInput.value = ''; });
babiesInput.addEventListener('blur', () => {
if (babiesInput.value === '') babiesInput.value = prev;
this.accoupState.babies = babiesInput.value;
});
}
// Register button
const registerBtn = this.el.querySelector('#accoup-register');
if (registerBtn) {
registerBtn.addEventListener('click', () => {
const { parent1, parent2 } = this.accoupState;
if (!parent1 || !parent2) return;
const baby = BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? '';
if (!baby) return;
const c = parseInt(this.accoupState.couples) || 0;
const b = parseInt(this.accoupState.babies) || 0;
if (c <= 0) return;
this.commandBus.execute({
type: 'register-accouplement',
parent1,
parent2,
baby,
gen: RACE_GEN[baby] ?? 0,
couples: c,
babiesObtained: b,
});
// Reset
this.accoupState = {
parent1: null, parent2: null,
filterGen: null, search: '',
couples: '', babies: '',
selectingSlot: 1,
};
this.dirty = true; this.update();
});
}
}
}
```
**Step 2 : Vérifier la compilation**
Run: `npm run build` (depuis Windows)
Expected: Build réussi, pas d'erreurs TypeScript.
**Step 3 : Tester visuellement**
Run: `npm start`
- Naviguer vers l'écran Accouplement
- Vérifier le layout 3 colonnes (Parent 1 | Cœur+inputs | Parent 2)
- Cliquer une race → remplit Parent 1
- La grille se filtre pour montrer les partenaires compatibles
- Cliquer un partenaire → remplit Parent 2
- Le bébé apparaît au centre + bouton Enregistrer actif
- Le bouton × vide un parent
- Les chips génération et la recherche fonctionnent
**Step 4 : Commit**
```bash
git add src/presentation/components/AccouplementView.ts
git commit -m "feat(accouplement): refonte layout single-page Obsidienne avec panneaux parents"
```
---
### Task 3 : Nettoyage et polish
**Files:**
- Modify: `src/presentation/styles/components.css` (vérifier qu'il ne reste rien d'accoup)
- Verify: `src/presentation/components/AccouplementView.ts`
**Step 1 : Vérifier qu'aucun ancien style `.accoup-*` ne reste dans components.css**
Chercher `accoup` dans `components.css`. Si des restes existent, les supprimer.
**Step 2 : Vérifier que le flow complet fonctionne**
Test manuel :
1. Sélectionner "Rousse" → P1 se remplit, grille montre partenaires compatibles
2. Sélectionner un partenaire → P2 se remplit, bébé apparaît au centre
3. Entrer couples=2, bébés=1
4. Cliquer "Enregistrer" → reset complet
5. Bouton × sur P1 → reset P1 et P2
6. Bouton × sur P2 → reset P2 seulement
7. Filtrer par Gen 2 → seules les races Gen 2 apparaissent
8. Rechercher "Rou" → filtre textuel fonctionne
**Step 3 : Commit final**
```bash
git add -A
git commit -m "chore(accouplement): nettoyage styles et polish refonte"
```

View File

@ -0,0 +1,62 @@
# Migration electron-updater + Gitea — Design
## Objectif
Remplacer le système de mise à jour custom (download HTTP manuel + script batch NSIS) par `electron-updater`, tout en conservant Gitea comme hébergeur de releases.
## Contrainte Gitea
Gitea ne supporte pas l'URL `/releases/latest/download/{filename}` (issue #31408 ouverte). On utilise donc une approche hybride :
1. Appel API Gitea pour découvrir la dernière version
2. `electron-updater` (generic provider) pour le cycle download → vérification sha512 → installation NSIS → restart
## Workflow de release
1. `npm run build` → génère `.exe` + `latest.yml` dans `dist/`
2. Créer le tag git, push
3. Sur Gitea : créer la release, uploader le `.exe` ET `latest.yml`
## Architecture
### Flux côté app
```
Démarrage (3s) → GET /api/v1/repos/mickael/dd-timer/releases/latest
→ Compare versions (tag_name vs app.getVersion())
→ Si update dispo :
autoUpdater.setFeedURL({
provider: "generic",
url: "https://gitea.mickael-pol.fr/mickael/dd-timer/releases/download/{tag}/"
})
autoUpdater.checkForUpdates()
→ electron-updater lit latest.yml
→ Télécharge le .exe (download-progress events)
→ update-downloaded → prêt à installer
→ quitAndInstall() au clic utilisateur
```
### Changements par fichier
| Fichier | Action |
|---------|--------|
| `package.json` | Ajouter `electron-updater` dep + config `publish` generic |
| `main.ts` | Supprimer download HTTP, redirections, script batch. Garder appel API Gitea. Ajouter autoUpdater events. |
| `preload.ts` | Inchangé (mêmes IPC channels) |
| `UpdateBanner.ts` | Inchangé |
### IPC channels (conservés)
- `update-available` → bannière disponible
- `update-downloading` → bannière téléchargement
- `update-progress` → { percent }
- `update-ready` → bouton "Installer et redémarrer"
- `update-error` → message erreur
- `install-update` → déclenche quitAndInstall()
## Avantages
- Suppression du script batch hack (~80 lignes)
- Vérification d'intégrité sha512 automatique
- Gestion NSIS install + restart native
- Code main.ts simplifié (~40 lignes vs ~200)
- Zéro infra supplémentaire

View File

@ -0,0 +1,390 @@
# Migration electron-updater + Gitea — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Remplacer le système de mise à jour custom (download HTTP + script batch) par `electron-updater`, en gardant Gitea comme source de releases.
**Architecture:** Approche hybride — appel API Gitea pour découvrir la dernière version (car Gitea n'a pas d'URL fixe "latest"), puis `electron-updater` (generic provider) pour le cycle download → vérification sha512 → installation NSIS → restart. Les IPC channels restent identiques pour que `UpdateBanner.ts` et `preload.ts` n'aient aucun changement.
**Tech Stack:** electron-updater 6.x, Electron 32.x, TypeScript
---
### Task 1: Installer electron-updater et configurer package.json
**Files:**
- Modify: `package.json`
**Step 1: Installer la dépendance**
```bash
npm install electron-updater
```
> `electron-updater` doit être dans `dependencies` (pas `devDependencies`) car il tourne dans l'app packagée.
**Step 2: Ajouter la config `publish` dans la section `build`**
Dans `package.json`, ajouter dans `"build"` :
```json
"publish": {
"provider": "generic",
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer/releases/download/latest"
}
```
> Cette URL est un placeholder — au runtime, on la remplace dynamiquement via `setFeedURL()`.
> Son seul rôle est de déclencher la génération de `latest.yml` par electron-builder.
**Step 3: Commit**
```bash
git add package.json package-lock.json
git commit -m "chore: add electron-updater dependency + publish config"
```
---
### Task 2: Réécrire la section mise à jour dans main.ts
**Files:**
- Modify: `src/infrastructure/electron/main.ts:1-16` (imports)
- Modify: `src/infrastructure/electron/main.ts:37-48` (UpdateInfo + state vars)
- Delete: `src/infrastructure/electron/main.ts:254-450` (tout le bloc update custom)
- Add: nouveau bloc update avec electron-updater (~60 lignes)
**Step 1: Ajouter l'import electron-updater, supprimer les imports inutiles**
Remplacer les imports en haut du fichier :
```typescript
import {
app,
BrowserWindow,
Tray,
Menu,
nativeImage,
ipcMain,
Notification,
dialog,
} from 'electron';
import path from 'path';
import fs from 'fs';
import { autoUpdater } from 'electron-updater';
```
> Supprimer : `import https from 'https'`, `import http from 'http'`, `import os from 'os'`, `import { spawn } from 'child_process'` — ils ne sont plus nécessaires pour les mises à jour.
>
> **Attention** : `https` et `http` sont encore utilisés par le bloc ntfy (lignes 214-243). Vérifier si d'autres usages existent avant de supprimer. Si ntfy les utilise → garder `https` et `http`. Supprimer seulement `os` et `spawn` si plus aucun usage.
**Step 2: Simplifier les variables d'état**
Remplacer l'interface `UpdateInfo` et les variables `updateInfo` / `downloading` :
```typescript
interface UpdateInfo {
version: string;
}
let updateInfo: UpdateInfo | null = null;
```
> On supprime `downloadUrl`, `assetName`, `releaseNotes` — electron-updater gère tout ça.
> On supprime `downloading` — electron-updater gère l'état.
**Step 3: Supprimer tout le bloc update custom**
Supprimer entièrement (lignes 254-450) :
- `compareVersions()`
- Interfaces `GiteaAsset`, `GiteaRelease`
- `checkForUpdates()`
- `startDownload()`
- `sendUpdateError()`
- `launchUpdater()`
**Step 4: Écrire le nouveau système de mise à jour**
Ajouter à la place du code supprimé :
```typescript
// ─── COMPARAISON DE VERSIONS ────────────────────────────────────────────────
function compareVersions(a: string, b: string): number {
const pa = a.replace(/^v/, '').split('.').map(Number);
const pb = b.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pb[i] || 0) > (pa[i] || 0)) return 1;
if ((pb[i] || 0) < (pa[i] || 0)) return -1;
}
return 0;
}
// ─── ELECTRON-UPDATER : CONFIGURATION ───────────────────────────────────────
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
// ─── ELECTRON-UPDATER : EVENTS ──────────────────────────────────────────────
autoUpdater.on('update-available', (info) => {
updateInfo = { version: info.version };
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-available', updateInfo);
}
fireNotification(
`Mise a jour v${info.version} disponible !`,
'Téléchargement en cours...'
);
rebuildTrayMenu();
});
autoUpdater.on('download-progress', (progress) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-progress', { percent: Math.round(progress.percent) });
}
});
autoUpdater.on('update-downloaded', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-ready', updateInfo ?? {});
}
});
autoUpdater.on('error', (err) => {
console.error('Update error:', err.message);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-error', { message: err.message });
}
});
// ─── VÉRIFICATION VIA API GITEA ─────────────────────────────────────────────
function checkForUpdates(silent = false): void {
const https = require('https') as typeof import('https');
const options: import('https').RequestOptions = {
hostname: GITEA_HOST,
port: 443,
path: `/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases?limit=1`,
method: 'GET',
headers: {
'User-Agent': `MinuteurDragodinde/${CURRENT_VERSION}`,
'Accept': 'application/json',
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk: string) => { data += chunk; });
res.on('end', () => {
try {
const releases = JSON.parse(data);
if (!Array.isArray(releases) || releases.length === 0) return;
const release = releases[0];
const latestVersion = release.tag_name;
if (!latestVersion || compareVersions(CURRENT_VERSION, latestVersion) <= 0) {
if (!silent && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-not-available');
}
return;
}
// Pointer electron-updater vers le tag de la release
const tag = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
autoUpdater.setFeedURL({
provider: 'generic',
url: `https://${GITEA_HOST}/${GITEA_USER}/${GITEA_REPO}/releases/download/${tag}`,
});
// electron-updater prend le relais : lit latest.yml, télécharge, vérifie
autoUpdater.checkForUpdates();
} catch (e: unknown) {
console.error('Update check parse error:', (e as Error).message);
}
});
});
req.on('error', (e: Error) => console.error('Update check error:', e.message));
req.end();
}
```
**Step 5: Mettre à jour le handler `install-update`**
Remplacer :
```typescript
ipcMain.on('install-update', () => startDownload());
```
Par :
```typescript
ipcMain.on('install-update', () => {
autoUpdater.quitAndInstall(true, true);
});
```
> `quitAndInstall(isSilent, isForceRunAfter)` : installe en silencieux et relance l'app.
**Step 6: Mettre à jour le tray menu**
Dans `rebuildTrayMenu()`, remplacer le click du menu update :
```typescript
if (updateInfo) {
items.push({ type: 'separator' });
items.push({
label: `⬆ Mise a jour v${updateInfo.version} disponible !`,
click: () => autoUpdater.quitAndInstall(true, true),
});
}
```
**Step 7: Simplifier le bloc did-finish-load**
Le bloc `did-finish-load` (ligne 115-133) reste identique — il envoie déjà `update-available` si `updateInfo` existe.
**Step 8: Commit**
```bash
git add src/infrastructure/electron/main.ts
git commit -m "feat: migrate update system to electron-updater"
```
---
### Task 3: Vérifier que preload.ts et UpdateBanner.ts n'ont pas besoin de changement
**Files:**
- Read: `src/infrastructure/electron/preload.ts`
- Read: `src/presentation/components/UpdateBanner.ts`
**Step 1: Vérifier les IPC channels**
Les channels IPC n'ont pas changé :
- `update-available``{ version }`
- `update-downloading`**ATTENTION** : l'ancien code envoyait `update-downloading`, mais electron-updater n'a pas cet event. Il passe directement de `update-available` à `download-progress`. Le banner state `downloading` est activé par `onUpdateDownloading` OU `onUpdateProgress`. Vérifier que `UpdateBanner` gère bien la transition.
**Step 2: Envoyer `update-downloading` explicitement**
Dans les events autoUpdater, ajouter après `update-available` :
```typescript
autoUpdater.on('update-available', (info) => {
updateInfo = { version: info.version };
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-available', updateInfo);
// Envoyer aussi downloading car autoDownload = true
mainWindow.webContents.send('update-downloading', { version: info.version });
}
fireNotification(
`Mise a jour v${info.version} disponible !`,
'Téléchargement en cours...'
);
rebuildTrayMenu();
});
```
> Cela garantit que `UpdateBanner` passe bien en état `downloading` comme avant.
**Step 3: Commit (si changement)**
```bash
git add src/infrastructure/electron/main.ts
git commit -m "fix: send update-downloading event for banner compatibility"
```
---
### Task 4: Vérifier la génération de latest.yml
**Step 1: Lancer un build**
```bash
npm run build
```
**Step 2: Vérifier que `dist/latest.yml` existe**
```bash
ls dist/latest.yml
cat dist/latest.yml
```
Contenu attendu (exemple) :
```yaml
version: 1.1.5
files:
- url: Minuteur-Dragodinde-Setup-1.1.5.exe
sha512: <hash>
size: <size>
path: Minuteur-Dragodinde-Setup-1.1.5.exe
sha512: <hash>
releaseDate: '2026-04-04T...'
```
**Step 3: Vérifier que le .exe est aussi généré**
```bash
ls dist/Minuteur-Dragodinde-Setup-*.exe
```
---
### Task 5: Mettre à jour le CHANGELOG
**Files:**
- Modify: `CHANGELOG.md`
**Step 1: Ajouter l'entrée**
Ajouter sous `## v1.1.6` (ou créer `## v1.1.7` si nouvelle version) :
```markdown
### Mise à jour automatique
- **Migration electron-updater** : remplacement du système custom (download HTTP + script batch) par `electron-updater` (generic provider)
- Vérification sha512 automatique des mises à jour
- Installation NSIS native (plus de script batch hack)
- Restart automatique après installation
- Code simplifié (~40 lignes vs ~200)
- Compatible Gitea : découverte via API + `latest.yml` uploadé en asset de release
```
**Step 2: Commit**
```bash
git add CHANGELOG.md
git commit -m "docs: add electron-updater migration to CHANGELOG"
```
---
### Task 6: Mettre à jour le workflow de release
**Step 1: Documenter le nouveau process**
Le nouveau workflow de release est :
1. Mettre à jour `version` dans `package.json`
2. `npm run build`
3. Deux fichiers sont générés dans `dist/` :
- `Minuteur-Dragodinde-Setup-X.Y.Z.exe`
- `latest.yml`
4. Commit + tag + push :
```bash
git add -A && git commit -m "vX.Y.Z"
git tag vX.Y.Z && git push && git push --tags
```
5. Sur Gitea → Releases → Créer release avec le tag
6. **Uploader les 2 fichiers** : le `.exe` ET `latest.yml`
> **IMPORTANT** : `latest.yml` DOIT être uploadé à chaque release, sinon electron-updater ne pourra pas vérifier l'intégrité du fichier.
---
## Récapitulatif des fichiers modifiés
| Fichier | Action |
|---------|--------|
| `package.json` | Ajout dep `electron-updater` + config `publish` |
| `package-lock.json` | Mis à jour automatiquement |
| `src/infrastructure/electron/main.ts` | Réécriture section update (~200 lignes → ~60 lignes) |
| `src/infrastructure/electron/preload.ts` | **Aucun changement** |
| `src/presentation/components/UpdateBanner.ts` | **Aucun changement** |
| `CHANGELOG.md` | Entrée migration |

View File

@ -0,0 +1,55 @@
# Toast Notifications — Design
**Goal:** Ajouter un systeme de toast notifications (bas droite) pour confirmer les actions et afficher les erreurs, en remplacement des `alert()` natifs.
## Types
| Type | Icone | Couleur | Duree |
|------|-------|---------|-------|
| `success` | `check_circle` | Vert | 3s |
| `error` | `error` | Rouge | 5s |
## Composant Toast.ts
Singleton avec file de toasts. API : `Toast.show(type, message)`.
- Max 3 toasts visibles (les plus anciens sont evincés)
- Empilage vertical vers le haut
- Animation : slide-in droite + fade-out a l'expiration
## Style
- Position : `bottom: 24px; right: 24px`, z-index eleve
- Glassmorphism Obsidienne (fond semi-transparent, blur, border subtle)
- Icone + texte sur une ligne, lisere gauche colore selon le type
- Pas de bouton d'action (undo/redo sera separe)
## Integration
- `App.ts` monte le conteneur `#toast-container` dans le DOM
- Chaque composant appelle `Toast.show()` apres `commandBus.execute()`
- Remplacer les `alert()` de `WorkflowsView.ts` par des toasts error
## Actions couvertes
### Success
- Suppression enclos
- Suppression DD
- Import workflows
- Enregistrement accouplement
- Reset statistiques
- Clear enclos
- Nouvelle fournee
- Export workflows
### Error
- Import workflows : fichier invalide, JSON invalide
- Toute erreur de validation future
## Fichiers
- **Creer** : `src/presentation/components/Toast.ts`
- **Modifier** : `src/presentation/styles/obsidienne.css`
- **Modifier** : `src/presentation/components/App.ts`
- **Modifier** : `src/presentation/components/WorkflowsView.ts`
- **Modifier** : composants avec actions destructives/importantes

View File

@ -0,0 +1,440 @@
# Toast Notifications Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Ajouter un systeme de toast notifications (success/error) en bas a droite pour donner du feedback apres les actions utilisateur.
**Architecture:** Composant singleton `Toast` avec file d'attente, monte dans le DOM par `App.ts`. Chaque composant appelle `Toast.show()` apres ses actions. Les `alert()` natifs sont remplaces par des toasts error.
**Tech Stack:** TypeScript, CSS animations, Material Symbols Outlined
---
### Task 1: Creer le composant Toast.ts
**Files:**
- Create: `src/presentation/components/Toast.ts`
**Step 1: Creer le fichier Toast.ts**
```typescript
export type ToastType = 'success' | 'error';
interface ToastItem {
id: number;
type: ToastType;
message: string;
}
const ICON: Record<ToastType, string> = {
success: 'check_circle',
error: 'error',
};
const DURATION: Record<ToastType, number> = {
success: 3000,
error: 5000,
};
const MAX_VISIBLE = 3;
let nextId = 0;
let container: HTMLElement | null = null;
const items: ToastItem[] = [];
export const Toast = {
mount(parent: HTMLElement): void {
container = document.createElement('div');
container.id = 'toast-container';
parent.appendChild(container);
},
show(type: ToastType, message: string): void {
if (!container) return;
const id = nextId++;
items.push({ id, type, message });
// Evincer les plus anciens si > MAX_VISIBLE
while (items.length > MAX_VISIBLE) {
const old = items.shift()!;
const oldEl = container.querySelector(`[data-toast-id="${old.id}"]`);
if (oldEl) oldEl.remove();
}
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.dataset['toastId'] = String(id);
el.innerHTML = `<span class="toast-icon material-symbols-outlined">${ICON[type]}</span><span class="toast-msg">${message}</span>`;
container.appendChild(el);
// Trigger animation
requestAnimationFrame(() => el.classList.add('toast-visible'));
setTimeout(() => {
el.classList.remove('toast-visible');
el.classList.add('toast-exit');
el.addEventListener('animationend', () => {
el.remove();
const idx = items.findIndex(i => i.id === id);
if (idx !== -1) items.splice(idx, 1);
}, { once: true });
}, DURATION[type]);
},
};
```
**Step 2: Run tests**
Run: `npm test` (depuis Windows)
Expected: PASS (aucun test casse, nouveau fichier sans test pour l'instant)
**Step 3: Commit**
```bash
git add src/presentation/components/Toast.ts
git commit -m "feat(toast): creer le composant Toast singleton"
```
---
### Task 2: Ajouter les styles CSS
**Files:**
- Modify: `src/presentation/styles/obsidienne.css`
**Step 1: Ajouter les styles toast a la fin de obsidienne.css**
Ajouter avant le dernier `}` ou a la fin du fichier :
```css
/* ────────────────────<E29480><E29480><EFBFBD>────────────────────────────
TOAST NOTIFICATIONS
───────────────────────<E29480><E29480><EFBFBD>───────────────────────── */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column-reverse;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-radius: 12px;
background: rgba(30, 20, 50, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #e0e0e0;
font-size: 0.92rem;
font-family: var(--font-body);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
pointer-events: auto;
opacity: 0;
transform: translateX(100%);
transition: none;
min-width: 260px;
max-width: 400px;
}
.toast-visible {
animation: toast-in 0.3s ease forwards;
}
.toast-exit {
animation: toast-out 0.3s ease forwards;
}
.toast-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.toast-msg {
flex: 1;
line-height: 1.3;
}
.toast-success {
border-left: 3px solid #4caf50;
}
.toast-success .toast-icon {
color: #4caf50;
}
.toast-error {
border-left: 3px solid #f44336;
}
.toast-error .toast-icon {
color: #f44336;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100%); }
}
```
**Step 2: Verifier visuellement**
Run: `npm start` (depuis Windows)
Verifier que l'app demarre sans erreur CSS.
**Step 3: Commit**
```bash
git add src/presentation/styles/obsidienne.css
git commit -m "style(toast): ajouter les styles glassmorphism pour les toasts"
```
---
### Task 3: Monter le conteneur Toast dans App.ts
**Files:**
- Modify: `src/presentation/components/App.ts:1-74`
**Step 1: Ajouter l'import Toast**
Apres la ligne `import { UpdateBanner } from './UpdateBanner';` (ligne 14), ajouter :
```typescript
import { Toast } from './Toast';
```
**Step 2: Monter le conteneur dans render()**
Apres le montage du update banner (apres la ligne `this.updateBanner.render(bannerRoot);`, environ ligne 73), ajouter :
```typescript
// Mount toast container
const appShell = this.root.querySelector('.app-shell') as HTMLElement;
Toast.mount(appShell);
```
**Step 3: Run tests**
Run: `npm test` (depuis Windows)
Expected: PASS
**Step 4: Commit**
```bash
git add src/presentation/components/App.ts
git commit -m "feat(toast): monter le conteneur Toast dans App.ts"
```
---
### Task 4: Remplacer les alert() dans WorkflowsView.ts
**Files:**
- Modify: `src/presentation/components/WorkflowsView.ts:349,653,665`
**Step 1: Ajouter l'import**
En haut de `WorkflowsView.ts`, ajouter :
```typescript
import { Toast } from './Toast';
```
**Step 2: Remplacer les alert() par des toasts error**
Ligne ~653 — remplacer :
```typescript
alert('Aucun plan valide trouvé dans le fichier.');
```
par :
```typescript
Toast.show('error', 'Aucun plan valide trouvé dans le fichier.');
```
Ligne ~665 — remplacer :
```typescript
alert('Le fichier sélectionné n\'est pas un JSON valide.');
```
par :
```typescript
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
```
**Step 3: Ajouter un toast success apres import reussi**
Apres la ligne `this.commandBus.execute({ type: 'import-workflows', ... })` (ligne ~660), avant `this.dirty = true;`, ajouter :
```typescript
Toast.show('success', `${valid.length} plan(s) importé(s) avec succès.`);
```
**Step 4: Ajouter un toast success apres suppression workflow**
Ligne ~350, apres `this.commandBus.execute({ type: 'delete-workflow', ... })`, ajouter :
```typescript
Toast.show('success', `Plan "${name}" supprimé.`);
```
**Step 5: Run tests**
Run: `npm test` (depuis Windows)
Expected: PASS
**Step 6: Commit**
```bash
git add src/presentation/components/WorkflowsView.ts
git commit -m "feat(toast): remplacer alert() par toasts dans WorkflowsView"
```
---
### Task 5: Ajouter les toasts dans les autres composants
**Files:**
- Modify: `src/presentation/components/App.ts:176` (delete-enclos)
- Modify: `src/presentation/components/EnclosView.ts:206,274` (clear-enclos, nouvelle-fournee)
- Modify: `src/presentation/components/Dashboard.ts:229` (reset-stats)
- Modify: `src/presentation/components/AccouplementView.ts:434` (register-accouplement)
- Modify: `src/presentation/components/DragodindeCard.ts:216` (remove-dragodinde)
**Step 1: App.ts — toast apres suppression enclos**
Ajouter l'import `Toast` (deja fait en Task 3). Apres `this.commandBus.execute({ type: 'delete-enclos', enclosId: id });` (ligne ~176), ajouter :
```typescript
Toast.show('success', 'Enclos supprimé.');
```
**Step 2: EnclosView.ts — toasts clear et nouvelle fournee**
Ajouter l'import en haut :
```typescript
import { Toast } from './Toast';
```
Apres `this.commandBus.execute({ type: 'clear-enclos', enclosId: eId });` (ligne ~206), ajouter :
```typescript
Toast.show('success', 'Enclos vidé.');
```
Apres `this.commandBus.execute({ type: 'nouvelle-fournee', enclosId: eId });` (ligne ~274), ajouter :
```typescript
Toast.show('success', 'Nouvelle fournée lancée.');
```
**Step 3: Dashboard.ts — toast reset stats**
Ajouter l'import :
```typescript
import { Toast } from './Toast';
```
Apres `this.commandBus.execute({ type: 'reset-stats' });` (ligne ~229), ajouter :
```typescript
Toast.show('success', 'Statistiques réinitialisées.');
```
**Step 4: AccouplementView.ts — toast enregistrement**
Ajouter l'import :
```typescript
import { Toast } from './Toast';
```
Apres le `this.commandBus.execute({ type: 'register-accouplement', ... })` (ligne ~434), ajouter :
```typescript
Toast.show('success', 'Accouplement enregistré.');
```
**Step 5: DragodindeCard.ts — toast suppression DD**
Ajouter l'import :
```typescript
import { Toast } from './Toast';
```
Apres `this.commandBus.execute({ type: 'remove-dragodinde', ... })` (ligne ~216), ajouter :
```typescript
Toast.show('success', 'Dragodinde retirée.');
```
**Step 6: Run tests**
Run: `npm test` (depuis Windows)
Expected: PASS
**Step 7: Commit**
```bash
git add src/presentation/components/App.ts src/presentation/components/EnclosView.ts src/presentation/components/Dashboard.ts src/presentation/components/AccouplementView.ts src/presentation/components/DragodindeCard.ts
git commit -m "feat(toast): ajouter toasts success sur toutes les actions importantes"
```
---
### Task 6: Ajouter un toast pour l'export workflows
**Files:**
- Modify: `src/presentation/components/WorkflowsView.ts`
**Step 1: Trouver la methode d'export**
Chercher la methode qui gere le clic sur le bouton d'export (probablement `exportWorkflows` ou similaire avec `showSaveDialog`). Apres l'ecriture reussie du fichier, ajouter :
```typescript
Toast.show('success', 'Plans exportés avec succès.');
```
**Step 2: Run tests**
Run: `npm test` (depuis Windows)
Expected: PASS
**Step 3: Commit**
```bash
git add src/presentation/components/WorkflowsView.ts
git commit -m "feat(toast): toast success apres export workflows"
```
---
### Task 7: Tests E2E pour les toasts
**Files:**
- Modify: `tests/e2e/breeding.spec.ts`
**Step 1: Ajouter un test E2E pour le toast d'enregistrement**
Dans le test "Definir nombre de couples et bebes puis enregistrer", apres le clic sur Enregistrer, ajouter une verification :
```typescript
// Verifier que le toast success apparait
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 3000 });
await expect(page.locator('.toast-msg')).toContainText('Accouplement enregistré');
```
**Step 2: Rebuild et run E2E**
Run: `npm run build && npx playwright test tests/e2e/breeding.spec.ts` (depuis Windows)
Expected: PASS
**Step 3: Commit**
```bash
git add tests/e2e/breeding.spec.ts
git commit -m "test(toast): test E2E toast apres enregistrement accouplement"
```