Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3555242c84 | |||
| 61bbac0adc | |||
| 6e0af993ca | |||
| be2f7a4b9b | |||
| 3e485fd09b | |||
| 8b5182b931 | |||
| 64a453e4fb | |||
| a98ee9c029 | |||
| 0c3b5e27a7 | |||
| 8af626dd66 | |||
| 2893013093 | |||
| 203c423f19 | |||
| 62ae4c54eb | |||
| c71ad151e0 | |||
| c640fbd416 |
19
.gitattributes
vendored
Executable file
19
.gitattributes
vendored
Executable file
@ -0,0 +1,19 @@
|
||||
# Normalise les fins de ligne : LF dans le repo, natif en working copy
|
||||
* text=auto eol=lf
|
||||
|
||||
# Fichiers texte explicites
|
||||
*.ts text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# Binaires — pas de conversion
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.exe binary
|
||||
*.node binary
|
||||
16
.gitignore
vendored
Normal file → Executable file
16
.gitignore
vendored
Normal file → Executable file
@ -1,5 +1,21 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-vite/
|
||||
dist-electron/
|
||||
dist-ts/
|
||||
*.log
|
||||
*.bak
|
||||
plans-dragodinde-*.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Playwright E2E
|
||||
.e2e-userdata/
|
||||
.e2e-userdata-persistence/
|
||||
test-results/
|
||||
playwright-report/
|
||||
# Coverage
|
||||
coverage/
|
||||
# Maquettes graphiques
|
||||
refonte_graphique/
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
103
CLAUDE.md
Normal file → Executable file
103
CLAUDE.md
Normal file → Executable file
@ -2,37 +2,81 @@
|
||||
|
||||
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 `docs/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
|
||||
|
||||
Minuteur Dragodinde is a Windows desktop app (Electron) for managing Dragodinde breeding timers in Dofus 3. French-language UI.
|
||||
Obsidienne (anciennement "Minuteur Dragodinde") is a Windows desktop app (Electron + Vite + TypeScript) for managing Dragodinde breeding timers in Dofus 3. French-language UI.
|
||||
|
||||
## Commands
|
||||
|
||||
- **Dev**: `npm start` (runs `electron .`)
|
||||
- **Build**: `npm run build` (produces NSIS installer in `dist/`)
|
||||
- **Dev**: `npm start` (runs Vite dev server + Electron via `vite-plugin-electron`)
|
||||
- **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
|
||||
|
||||
No test framework or linter is configured.
|
||||
|
||||
## 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/index.html` — **Monolithic ~2200-line file** containing all HTML, CSS, and JS in one file. This is the entire renderer process.
|
||||
- `icon.png` — App icon (256x256)
|
||||
```
|
||||
src/
|
||||
domain/ — Entities, value objects, domain services (pure TS, no deps)
|
||||
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`.
|
||||
- **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).
|
||||
- **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.
|
||||
- **Dashboard/Stats views**: Special `activeId` values (`'dashboard'`, `'stats'`) for overview and statistics pages.
|
||||
### Application layer
|
||||
|
||||
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)
|
||||
|
||||
Queries: `GetDashboard`, `GetEnclosDetail`, `GetTimerState`, `GetBreedingOptions`, `GetReapproTree`, `GetInventaire`, `GetSettings`, `GetWorkflows`
|
||||
|
||||
### 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
|
||||
|
||||
@ -45,11 +89,26 @@ Checks Gitea Releases API on startup (after 3s) and hourly. Downloads NSIS Setup
|
||||
|
||||
### Dev vs packaged mode
|
||||
|
||||
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 `Obsidienne-DEV` (isolated from installed app) and a DEV badge is injected into the UI. On first packaged launch, data is auto-migrated from the old `Minuteur Dragodinde` folder.
|
||||
|
||||
## 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
|
||||
|
||||
- All UI text is in French
|
||||
- No external JS dependencies in renderer — everything is vanilla JS
|
||||
- CSS uses custom properties defined in `:root` (color theme: dark purple/gaming aesthetic)
|
||||
- Electron config: `contextIsolation: true`, `nodeIntegration: false`, `backgroundThrottling: false`
|
||||
- TypeScript strict mode — no `any` unless unavoidable
|
||||
- Immutable domain entities (functions return new objects)
|
||||
- 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/`
|
||||
|
||||
243
README.md
Normal file → Executable file
243
README.md
Normal file → Executable file
@ -1,26 +1,45 @@
|
||||
# ⚔ Minuteur Dragodinde — Dofus 3
|
||||
# ⚔ Obsidienne — Élevage Dragodinde (Dofus 3)
|
||||
|
||||
Outil de gestion d'élevage de Dragodindes pour Dofus 3.
|
||||
Application desktop Windows construite avec Electron.
|
||||
Application desktop Windows de gestion d'élevage de Dragodindes pour Dofus 3.
|
||||
Construite avec **Electron + Vite + TypeScript** en architecture **DDD hexagonale**.
|
||||
|
||||
🔗 **Repo** : https://gitea.mickael-pol.fr/mickael/dd-timer
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Élevage & Timers
|
||||
- 🐦 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)
|
||||
- 📊 **Dashboard** vue d'ensemble multi-enclos
|
||||
- 🔔 **Notifications natives Windows** même application en arrière-plan
|
||||
- 🔊 **4 sons d'alarme** au choix (Arpège, Pulsation, Fanfare, Cloche)
|
||||
- 🐉 **Jauges** : Baffeur, Caresseur, Foudroyeur, Abreuvoir, Dragofesse, Mangeoire (XP)
|
||||
- 🐉 **6 jauges** : Baffeur, Caresseur, Foudroyeur, Abreuvoir, Dragofesse, Mangeoire (XP)
|
||||
- 🔄 **Recharge de jauge** en cours de session avec recalcul multi-segments
|
||||
- 🎯 **Cibles sérénité et niveau** par Dragodinde avec ETA en temps réel
|
||||
- 🖱 **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
|
||||
- 📱 **Notifications mobiles** via ntfy (serveur self-hosted)
|
||||
- 💾 Sauvegarde automatique locale
|
||||
|
||||
### Design
|
||||
- 🎨 **Design system Obsidienne** — glassmorphism, Material Design 3, Material Symbols Outlined
|
||||
- 🌙 Interface sombre avec thème violet/gaming
|
||||
|
||||
## 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 `Obsidienne Setup x.x.x.exe` depuis la section [Releases](https://gitea.mickael-pol.fr/mickael/dd-timer/releases)
|
||||
2. **Clic droit → Propriétés → cocher "Débloquer" → OK** (important, une seule fois)
|
||||
3. Double-cliquer pour lancer l'installation
|
||||
4. L'app apparaît dans le menu Démarrer et sur le Bureau
|
||||
@ -32,44 +51,210 @@ Application desktop Windows construite avec Electron.
|
||||
### Prérequis
|
||||
- [Node.js LTS](https://nodejs.org)
|
||||
|
||||
### Compiler
|
||||
### Commandes
|
||||
|
||||
```bash
|
||||
# Double-cliquer sur build.bat (admin auto)
|
||||
# ou manuellement :
|
||||
npm install
|
||||
npm run build
|
||||
npm install # Installer les dépendances
|
||||
npm start # Dev (Vite + Electron)
|
||||
npm test # Tests unitaires (Vitest, depuis Windows)
|
||||
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/`.
|
||||
|
||||
### Tests
|
||||
|
||||
#### Tests unitaires, fonctionnels et régression
|
||||
|
||||
**366 tests** via Vitest — couverture de code **94%** (v8).
|
||||
|
||||
```
|
||||
npm test # Lancer tous les tests
|
||||
npm run test:watch # Mode watch
|
||||
npm run test:coverage # Avec rapport de couverture
|
||||
```
|
||||
|
||||
| Couche | Fichiers | Tests | Statements | Branches | Functions | Lines |
|
||||
|--------|----------|-------|-----------|----------|-----------|-------|
|
||||
| **Domain** | 14 | 165 | 94–100% | 80–100% | 92–100% | 85–100% |
|
||||
| **Application** | 3 | 128 | 92–100% | 76–100% | 100% | 96–100% |
|
||||
| **Infrastructure** | 2 | 19 | 100% | 100% | 100% | 100% |
|
||||
| **Presentation** | 3 | 36 | — | — | — | — |
|
||||
| **Régression** | 9 | 48 | — | — | — | — |
|
||||
| **Fonctionnel** | 3 | 8 | — | — | — | — |
|
||||
|
||||
La couche Presentation est testée avec `happy-dom` pour Toast, ConfirmModal et UndoManager.
|
||||
|
||||
#### Tests E2E
|
||||
|
||||
**24 tests E2E** via Playwright + Electron.
|
||||
|
||||
```
|
||||
npm run build && npm run test:e2e
|
||||
```
|
||||
|
||||
| Suite | Tests | Couverture |
|
||||
|-------|-------|------------|
|
||||
| **Navigation sidebar** | 8 | Tableau de bord, statistiques, enclos, accouplement, réappro, inventaire, workflows, paramètres |
|
||||
| **Cycle de vie du timer** | 4 | Activation jauge, démarrage, pause/reprise, reset |
|
||||
| **Recharge de jauge** | 2 | Mise à jour "Alarme dans" et barre de jauge en temps réel |
|
||||
| **Workflow d'accouplement** | 5 | Sélection parents, résultat bébé, enregistrement, filtres, recherche |
|
||||
| **Persistance** | 1 | Survie des données après fermeture/réouverture |
|
||||
| **UI Feedback** | 4 | Toast, ConfirmModal, Undo via toast |
|
||||
|
||||
#### Sécurité et qualité
|
||||
|
||||
Les tests incluent des vérifications de :
|
||||
- **Sécurité XSS** — `esc()` <20><>chappe correctement `<`, `>`, `"`, `&`
|
||||
- **Sécurité import** — validation structurelle complète des données importées (14 cas)
|
||||
- **Validation HTTPS** — rejet des URLs non-HTTPS pour ntfy
|
||||
- **Protection textContent** — Toast et ConfirmModal utilisent `textContent` (pas `innerHTML`)
|
||||
|
||||
> ⚠ Les tests doivent être lancés depuis un **terminal Windows** (pas WSL) — les bindings natifs Electron ne fonctionnent pas en WSL.
|
||||
|
||||
## 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
|
||||
# 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/ :
|
||||
# - Obsidienne Setup 1.x.x.exe
|
||||
# - latest.yml (version + sha512, requis par electron-updater)
|
||||
|
||||
# 3. Committer et tagger
|
||||
git add .
|
||||
git commit -m "v1.x.x - description"
|
||||
git tag v1.x.x
|
||||
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 :
|
||||
# - Obsidienne 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/index.html # Interface (HTML/CSS/JS)
|
||||
├── main.js # Processus principal Electron + auto-update
|
||||
├── preload.js # Bridge Electron ↔ renderer
|
||||
├── icon.png # Icône (256x256)
|
||||
├── package.json # Config et dépendances
|
||||
└── build.bat # Script de build Windows (admin auto)
|
||||
src/
|
||||
├── domain/ — Entités, value objects, services purs (aucune dépendance)
|
||||
├── application/ — CommandBus/QueryBus, commandes et requêtes (CQRS)
|
||||
├── infrastructure/ — LocalStorage, Electron IPC, notifications, audio
|
||||
└── presentation/ — Composants UI, helpers live, styles CSS, état UI
|
||||
```
|
||||
|
||||
## 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
|
||||
- ✨ **Toast notifications** — feedbacks visuels success/error en bas à droite avec glassmorphism, auto-dismiss (3s/5s/10s), bouton d'action optionnel (ex: Annuler)
|
||||
- ✨ **Modale de confirmation glassmorphism** — remplace tous les `confirm()` et `alert()` natifs par une modale stylisée, clic extérieur = annulation
|
||||
- ✨ **Undo actions destructives** — snapshot/restore 1 niveau avec expiration 10s, bouton Annuler dans le toast + raccourci Ctrl+Z
|
||||
- ✨ **Backup/export global** — export JSON avec métadonnées (app, version, date), import avec validation et confirmation, accessible depuis Paramètres
|
||||
- ✨ **É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)
|
||||
|
||||
#### Sécurité & Robustesse
|
||||
- 🔒 **Sandbox Electron** — `sandbox: true` dans webPreferences pour isoler le renderer
|
||||
- 🔒 **HTTPS forcé pour ntfy** — rejet des URLs HTTP, seules les connexions chiffrées sont acceptées
|
||||
- 🔒 **Suppression de `executeJavaScript`** — badge DEV migré vers IPC sécurisé (`dev-mode`)
|
||||
- 🔒 **Validation structurelle import** — vérification complète du schéma (id, name, dragodindes, gaugeLevels, timer) avant import
|
||||
- 🔒 **Sanitisation XSS** — `esc()` sur toutes les données utilisateur + `textContent` dans Toast/ConfirmModal
|
||||
- 🛡 **try/catch sur tous les appels electronAPI** — export, import, workflows (plus de crash silencieux)
|
||||
- 🛡 **Erreurs de sauvegarde loggées** — `LocalStorageRepository.save()` ne masque plus les erreurs
|
||||
- 🛡 **Promesse Sidebar catchée** — `getVersion()` ne génère plus d'erreur non gérée
|
||||
- 🛡 **Nettoyage Ctrl+Z listener** — `removeEventListener` dans `destroy()` pour éviter les memory leaks
|
||||
- 🛡 **Toast stale container** — protection `isConnected` contre les conteneurs DOM détachés
|
||||
|
||||
#### UI
|
||||
- 🐛 **Double cercle blanc inventaire** — les images de dragodindes dans l'inventaire utilisent maintenant le même style carré arrondi que l'accouplement et le réappro
|
||||
|
||||
#### Renommage
|
||||
- 🏷 **Renommage "Minuteur Dragodinde" → "Obsidienne"** — nouveau nom d'application, raccourcis, titre, tray, notifications
|
||||
- 🔄 **Migration automatique des données** — copie transparente du fichier de sauvegarde depuis l'ancien dossier `%APPDATA%\Minuteur Dragodinde\` au premier lancement
|
||||
- 🔄 **GUID NSIS fixe** — l'installeur reconnaît l'ancienne version et la remplace proprement (pas de doublon)
|
||||
- 🔄 **Rétrocompatibilité import** — les backups exportés avec `app: 'minuteur-dragodinde'` restent importables
|
||||
|
||||
#### Nettoyage
|
||||
- 🗑 **Suppression scripts build.bat / build.ps1** — `npm run build` suffit
|
||||
- 🗑 **Suppression maquettes graphiques** du suivi Git (refonte_graphique/)
|
||||
|
||||
#### Technique
|
||||
- ⬆ **Migration electron-updater** — vérification sha512 via `latest.yml`, installation NSIS native, restart auto
|
||||
- 🎨 **Icône Windows native** — migration `icon.png` → `icon.ico`
|
||||
- 🧪 **366 tests** (unitaires, fonctionnels, régression, sécurité) — couverture **94%** via Vitest + v8
|
||||
- 🧪 **24 tests E2E** Playwright + Electron — navigation, timer, recharge jauge, accouplement, persistance, toast, modale de confirmation, undo
|
||||
|
||||
### 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
|
||||
- ✨ **Sidebar navigation** — menu hamburger avec panneau lateral overlay (Dashboard, Enclos, Accouplement, Reappro, Inventaire, Workflows, Parametres)
|
||||
@ -131,8 +316,16 @@ dd-timer/
|
||||
- 📝 **Sous-onglets par enclos** (Elevage / Historique bebes)
|
||||
- 🔧 **Mode DEV** — Donnees isolees et badge DEV visible quand lance avec `npm start`
|
||||
- ⬆ **Mise a jour automatique** via Gitea Releases avec banniere de progression dans l'interface
|
||||
- 🔧 Correction de l'identifiant applicatif (`fr.mickael-pol.minuteur-dragodinde`)
|
||||
- 🔧 Correction de l'identifiant applicatif (`fr.mickael-pol.obsidienne`)
|
||||
- 🔧 Masquage des spinners natifs sur les champs numeriques
|
||||
|
||||
### v1.0.0
|
||||
- Version initiale
|
||||
|
||||
---
|
||||
|
||||
## Mentions légales
|
||||
|
||||
Dofus et Dragodinde sont des marques déposées d'**Ankama Games**. Cette application est un projet non officiel, développé par un fan. Elle n'est ni affiliée, ni approuvée, ni sponsorisée par Ankama Games.
|
||||
|
||||
Les icônes utilisent [Material Symbols](https://fonts.google.com/icons) (Apache 2.0). Les polices proviennent de [Google Fonts](https://fonts.google.com/) (OFL).
|
||||
|
||||
255
algorithmes.md
255
algorithmes.md
@ -1,255 +0,0 @@
|
||||
# Algorithmes de calcul — Minuteur Dragodinde
|
||||
|
||||
Ce document décrit tous les algorithmes utilisés dans l'application, expliqués simplement.
|
||||
|
||||
---
|
||||
|
||||
## 1. Système de tiers des jauges
|
||||
|
||||
Chaque jauge (baffeur, caresseur, foudroyeur, abreuvoir, dragofesse, mangeoire) a un **niveau** entre 0 et 100 000.
|
||||
Ce niveau détermine un **tier** qui fixe la vitesse de conversion :
|
||||
|
||||
| Niveau de jauge | Tier | Taux (pts / 10 sec) |
|
||||
|-----------------|------|---------------------|
|
||||
| 0 – 40 000 | 1 | 10 |
|
||||
| 40 001 – 70 000 | 2 | 20 |
|
||||
| 70 001 – 90 000 | 3 | 30 |
|
||||
| 90 001 – 100 000 | 4 | 40 |
|
||||
|
||||
**Principe** : La jauge se **vide** en donnant des points à la DD. Plus la jauge est haute, plus elle se vide vite (tier élevé = plus de points par tick).
|
||||
|
||||
Un "tick" = 10 secondes de timer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Calcul des points gagnés dans le temps — `gainedIn(lvl, sec)`
|
||||
|
||||
**Question** : "Si ma jauge démarre au niveau `lvl` et tourne pendant `sec` secondes, combien de points ma DD gagne-t-elle ?"
|
||||
|
||||
**Algorithme** :
|
||||
1. On part du haut (tier 4) et on descend
|
||||
2. Pour chaque palier, on calcule combien de ticks on peut consommer avant de passer au palier en-dessous
|
||||
3. On additionne les points de chaque palier traversé
|
||||
|
||||
**Exemple** : Jauge à 95 000, timer 60 sec (= 6 ticks)
|
||||
- Tier 4 (90k–100k) : 5 000 pts dispo ÷ 40/tick = 125 ticks possibles → on en utilise 6 → gain = 6 × 40 = **240 pts**
|
||||
- Jauge restante : 95 000 - 240 = 94 760
|
||||
|
||||
La jauge descend au fur et à mesure qu'elle donne des points. Quand elle passe sous un seuil de tier, le taux ralentit.
|
||||
|
||||
---
|
||||
|
||||
## 3. Temps pour gagner X points — `timeToGain(lvl, pts)`
|
||||
|
||||
**Question** : "Combien de temps faut-il pour gagner `pts` points depuis une jauge au niveau `lvl` ?"
|
||||
|
||||
C'est l'inverse de `gainedIn`. On parcourt les paliers du haut vers le bas :
|
||||
1. Pour chaque tier, combien de points peut-on donner avant de descendre au palier suivant ?
|
||||
2. On prend le minimum entre les points disponibles et les points restants à donner
|
||||
3. On calcule le temps correspondant : `ceil(points / taux) × 10 sec`
|
||||
|
||||
Si la jauge se vide complètement avant d'atteindre l'objectif → retourne `Infinity` (impossible).
|
||||
|
||||
---
|
||||
|
||||
## 4. Niveau de jauge après X secondes — `gaugeAfter(lvl, sec)`
|
||||
|
||||
**Question** : "Si ma jauge démarre à `lvl` et tourne `sec` secondes, à quel niveau sera-t-elle ?"
|
||||
|
||||
Même logique que `gainedIn`, mais au lieu de compter les points donnés, on soustrait directement du niveau de la jauge.
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps écoulé — `elapsed(enc)`
|
||||
|
||||
Calcule les secondes écoulées depuis le démarrage du timer d'un enclos, en excluant les pauses :
|
||||
|
||||
```
|
||||
Si en cours : (maintenant - démarrage - temps_en_pause) / 1000
|
||||
Si en pause : (moment_pause - démarrage - temps_en_pause) / 1000
|
||||
Si non démarré : 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 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 :
|
||||
- La stat estimée actuelle
|
||||
- Si la cible est atteinte
|
||||
- Le % de progression
|
||||
- Le countdown restant
|
||||
|
||||
### 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
|
||||
2. On calcule les points gagnés depuis le snapshot : `gainedIn(snapshotJauge, tempsÉcoulé)`
|
||||
3. On applique la direction (`dir`) :
|
||||
- `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte
|
||||
- `dir = -1` (baffeur) : stat descend
|
||||
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`
|
||||
|
||||
### Pour la mangeoire (XP) — modèle spécial :
|
||||
|
||||
L'XP utilise un **modèle linéaire constant** car le joueur est supposé **recharger** la jauge manuellement :
|
||||
|
||||
1. On détermine le taux fixe = `tierRate(snapshotJauge)` au démarrage
|
||||
2. XP gagnée = `nombre_de_ticks × taux` (pas de dégression)
|
||||
3. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)`
|
||||
4. Countdown = `ceil(xp_restante / taux) × 10 sec`
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Table d'XP et niveaux
|
||||
|
||||
### Table XP_RAW
|
||||
|
||||
Dictionnaire de 200 entrées : `niveau → XP cumulatif total`.
|
||||
|
||||
Exemples :
|
||||
- Niveau 1 → 0 XP
|
||||
- Niveau 10 → 809 XP
|
||||
- Niveau 50 → 34 365 XP
|
||||
- Niveau 100 → 172 668 XP
|
||||
- Niveau 200 → 867 582 XP
|
||||
|
||||
Les valeurs sont **cumulatives** (pas incrémentales).
|
||||
|
||||
### `xpForLevel(lvl)` : Retourne l'XP cumulatif pour atteindre le niveau `lvl`.
|
||||
|
||||
### `levelFromXp(xp)` : Retourne le plus haut niveau atteint avec `xp` points d'XP cumulatifs. Parcourt la table de 200 à 1 pour trouver le seuil.
|
||||
|
||||
---
|
||||
|
||||
## 8. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive`
|
||||
|
||||
### Version statique (`calcSerenEta`)
|
||||
1. Calcule `diff = cible - sérénité actuelle`
|
||||
2. Si `diff > 0` → besoin du caresseur (monte la sérénité)
|
||||
3. Si `diff < 0` → besoin du baffeur (baisse la sérénité)
|
||||
4. Temps = `timeToGain(niveauJauge, |diff|)`
|
||||
|
||||
### Version live (`calcSerenEtaLive`)
|
||||
1. Utilise `computeGaugeLive` pour obtenir la sérénité estimée en temps réel
|
||||
2. Recalcule le diff depuis cette estimation
|
||||
3. Calcule le temps restant avec les points encore à parcourir
|
||||
|
||||
---
|
||||
|
||||
## 9. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive`
|
||||
|
||||
### Version statique (`calcLevelEta`)
|
||||
1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)`
|
||||
2. Taux fixe = `tierRate(niveauMangeoire)`
|
||||
3. Temps = `ceil(xpNécessaire / taux) × 10 sec`
|
||||
|
||||
### Version live (`calcLevelEtaLive`)
|
||||
1. Utilise `computeGaugeLive` pour obtenir le niveau estimé actuel
|
||||
2. XP restante = `xpForLevel(cible) - xpForLevel(niveauEstimé)`
|
||||
3. Taux fixe = `tierRate(snapshotMangeoire)`
|
||||
4. Countdown = `ceil(xpRestante / taux) × 10 sec`
|
||||
|
||||
---
|
||||
|
||||
## 10. Countdown global d'un enclos — `enclosGlobalState(enc)`
|
||||
|
||||
**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`
|
||||
2. Prendre le **maximum** de tous les countdowns = le plus long à terminer
|
||||
3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`)
|
||||
4. `allDone = true` si toutes les DD sont terminées
|
||||
|
||||
---
|
||||
|
||||
## 11. 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 ?"
|
||||
|
||||
### Principe : décomposition récursive par génération
|
||||
|
||||
Chaque race de génération ≥ 2 est produite par le croisement de 2 races parentes (table `BREEDING_RECIPES`).
|
||||
|
||||
**Algorithme** :
|
||||
1. On part de la race cible et de la quantité voulue
|
||||
2. Pour chaque génération (de la plus haute à gen 2) :
|
||||
- Pour chaque race nécessaire à cette génération :
|
||||
- Calculer le nombre de couples nécessaires (voir §11.1)
|
||||
- Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool
|
||||
3. Les races gen 1 restantes = matières premières
|
||||
|
||||
### 11.1 Mécanisme du reproducteur
|
||||
|
||||
Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés.
|
||||
|
||||
```
|
||||
Si 2×R ≥ Q → couples = ceil(Q / 2)
|
||||
Sinon → couples = Q - R
|
||||
```
|
||||
|
||||
Où `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()`
|
||||
|
||||
**Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?"
|
||||
|
||||
### Modèle de données
|
||||
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
|
||||
|
||||
Pour chaque génération (2 → 10) :
|
||||
1. Lister tous les croisements possibles à cette génération
|
||||
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)
|
||||
- Si même race (A = B) : vérifier qu'on a ≥ 2 individus dont au moins 1 ♂ et 1 ♀
|
||||
- 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
|
||||
```
|
||||
takeMale : si m > 0 → m--, 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
|
||||
|
||||
| Stat | Min | Max | Jauge(s) associée(s) |
|
||||
|-----------|--------|--------|----------------------|
|
||||
| Sérénité | -5 000 | 5 000 | Baffeur (↓), Caresseur (↑) |
|
||||
| Endurance | 0 | 20 000 | Foudroyeur (↑) |
|
||||
| Maturité | 0 | 20 000 | Abreuvoir (↑) |
|
||||
| Amour | 0 | 20 000 | Dragofesse (↑) |
|
||||
| Niveau/XP | 1 | 200 | Mangeoire (↑) |
|
||||
|
||||
---
|
||||
|
||||
## 14. Système de snapshots
|
||||
|
||||
Quand le timer démarre, on prend un **snapshot** de l'état :
|
||||
- `snapGauges` : niveaux de toutes les jauges au moment du démarrage
|
||||
- `snapStats[dd.id]` : stats de chaque DD au moment du démarrage
|
||||
|
||||
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.
|
||||
50
build.bat
50
build.bat
@ -1,50 +0,0 @@
|
||||
@echo off
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs -Wait"
|
||||
exit /b
|
||||
)
|
||||
cd /d "%~dp0"
|
||||
set CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||
set CSC_LINK=
|
||||
set WIN_CSC_LINK=
|
||||
echo.
|
||||
echo ================================================
|
||||
echo MINUTEUR DRAGODINDE - Construction du .exe
|
||||
echo ================================================
|
||||
echo.
|
||||
node -v >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERREUR] Node.js introuvable.
|
||||
echo Installez depuis https://nodejs.org
|
||||
start https://nodejs.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
for /f "tokens=*" %%v in ('node -v') do echo [OK] Node.js %%v
|
||||
|
||||
echo Nettoyage cache...
|
||||
if exist "%LOCALAPPDATA%\electron-builder\Cache\winCodeSign" rmdir /s /q "%LOCALAPPDATA%\electron-builder\Cache\winCodeSign"
|
||||
echo [OK] Cache nettoye
|
||||
echo.
|
||||
|
||||
echo [1/3] Installation des dependances...
|
||||
call npm install
|
||||
if %errorlevel% neq 0 ( echo [ERREUR] npm install & pause & exit /b 1 )
|
||||
echo [OK] Dependances OK
|
||||
echo.
|
||||
|
||||
echo [2/3] Construction du setup .exe (2-5 minutes)...
|
||||
call npm run build
|
||||
if %errorlevel% neq 0 ( echo [ERREUR] Build echoue & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo [3/3] Termine !
|
||||
echo.
|
||||
echo ================================================
|
||||
echo Installeur pret dans le dossier dist\
|
||||
echo Fichier : Minuteur Dragodinde Setup 1.0.0.exe
|
||||
echo ================================================
|
||||
echo.
|
||||
if exist dist explorer dist
|
||||
pause
|
||||
71
build.ps1
71
build.ps1
@ -1,71 +0,0 @@
|
||||
# Minuteur Dragodinde - Script de construction
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Host.UI.RawUI.WindowTitle = 'Minuteur Dragodinde - Construction'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '================================================' -ForegroundColor Cyan
|
||||
Write-Host ' MINUTEUR DRAGODINDE - Construction du .exe' -ForegroundColor Cyan
|
||||
Write-Host '================================================' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
# Verification Node.js
|
||||
try {
|
||||
$nodeVer = node -v 2>&1
|
||||
Write-Host "[OK] Node.js : $nodeVer" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host '[ERREUR] Node.js introuvable. Installez depuis https://nodejs.org' -ForegroundColor Red
|
||||
Start-Process 'https://nodejs.org'
|
||||
Read-Host 'Appuyez sur Entree pour fermer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Verification npm
|
||||
try {
|
||||
$npmVer = npm -v 2>&1
|
||||
Write-Host "[OK] npm : $npmVer" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host '[ERREUR] npm introuvable.' -ForegroundColor Red
|
||||
Read-Host 'Appuyez sur Entree pour fermer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '[1/3] Installation des dependances (~150 Mo, premiere fois)...' -ForegroundColor Yellow
|
||||
Write-Host ''
|
||||
|
||||
npm install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host '[ERREUR] npm install a echoue.' -ForegroundColor Red
|
||||
Read-Host 'Appuyez sur Entree pour fermer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '[OK] Dependances installees' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host '[2/3] Construction du logiciel Windows (2-5 minutes)...' -ForegroundColor Yellow
|
||||
Write-Host ''
|
||||
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host '[ERREUR] Build echoue - voir messages ci-dessus.' -ForegroundColor Red
|
||||
Read-Host 'Appuyez sur Entree pour fermer'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '[3/3] Termine !' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host '================================================' -ForegroundColor Cyan
|
||||
Write-Host ' Installeur pret dans le dossier dist' -ForegroundColor Cyan
|
||||
Write-Host ' Minuteur Dragodinde Setup 1.0.0.exe' -ForegroundColor Cyan
|
||||
Write-Host '================================================' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
|
||||
if (Test-Path 'dist') {
|
||||
Invoke-Item 'dist'
|
||||
}
|
||||
|
||||
Read-Host 'Appuyez sur Entree pour fermer'
|
||||
469
docs/algorithmes.md
Executable file
469
docs/algorithmes.md
Executable file
@ -0,0 +1,469 @@
|
||||
# Algorithmes de calcul — Obsidienne
|
||||
|
||||
Ce document décrit tous les algorithmes utilisés dans l'application, expliqués simplement.
|
||||
|
||||
---
|
||||
|
||||
## 1. Système de tiers des jauges
|
||||
|
||||
Chaque jauge (baffeur, caresseur, foudroyeur, abreuvoir, dragofesse, mangeoire) a un **niveau** entre 0 et 100 000.
|
||||
Ce niveau détermine un **tier** qui fixe la vitesse de conversion :
|
||||
|
||||
| Niveau de jauge | Tier | Taux (pts / 10 sec) |
|
||||
|-----------------|------|---------------------|
|
||||
| 0 – 40 000 | 1 | 10 |
|
||||
| 40 001 – 70 000 | 2 | 20 |
|
||||
| 70 001 – 90 000 | 3 | 30 |
|
||||
| 90 001 – 100 000 | 4 | 40 |
|
||||
|
||||
**Principe** : La jauge se **vide** en donnant des points à la DD. Plus la jauge est haute, plus elle se vide vite (tier élevé = plus de points par tick).
|
||||
|
||||
Un "tick" = 10 secondes de timer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Calcul des points gagnés dans le temps — `gainedIn(lvl, sec)`
|
||||
|
||||
**Question** : "Si ma jauge démarre au niveau `lvl` et tourne pendant `sec` secondes, combien de points ma DD gagne-t-elle ?"
|
||||
|
||||
**Algorithme** :
|
||||
1. On part du haut (tier 4) et on descend
|
||||
2. Pour chaque palier, on calcule combien de ticks on peut consommer avant de passer au palier en-dessous
|
||||
3. On additionne les points de chaque palier traversé
|
||||
|
||||
**Exemple** : Jauge à 95 000, timer 60 sec (= 6 ticks)
|
||||
- Tier 4 (90k–100k) : 5 000 pts dispo ÷ 40/tick = 125 ticks possibles → on en utilise 6 → gain = 6 × 40 = **240 pts**
|
||||
- Jauge restante : 95 000 - 240 = 94 760
|
||||
|
||||
La jauge descend au fur et à mesure qu'elle donne des points. Quand elle passe sous un seuil de tier, le taux ralentit.
|
||||
|
||||
---
|
||||
|
||||
## 3. Temps pour gagner X points — `timeToGain(lvl, pts)`
|
||||
|
||||
**Question** : "Combien de temps faut-il pour gagner `pts` points depuis une jauge au niveau `lvl` ?"
|
||||
|
||||
C'est l'inverse de `gainedIn`. On parcourt les paliers du haut vers le bas :
|
||||
1. Pour chaque tier, combien de points peut-on donner avant de descendre au palier suivant ?
|
||||
2. On prend le minimum entre les points disponibles et les points restants à donner
|
||||
3. On calcule le temps correspondant : `ceil(points / taux) × 10 sec`
|
||||
|
||||
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)`
|
||||
|
||||
**Question** : "Si ma jauge démarre à `lvl` et tourne `sec` secondes, à quel niveau sera-t-elle ?"
|
||||
|
||||
Même logique que `gainedIn`, mais au lieu de compter les points donnés, on soustrait directement du niveau de la jauge.
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps écoulé — `elapsed` et `elapsedLive`
|
||||
|
||||
### `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 pause : (moment_pause - démarrage - temps_en_pause) / 1000
|
||||
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. 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 :
|
||||
- La stat estimée actuelle
|
||||
- Si la cible est atteinte
|
||||
- Le % de progression
|
||||
- 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) :
|
||||
|
||||
1. On récupère le **snapshot** de la jauge et de la stat au moment du démarrage du timer
|
||||
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`) :
|
||||
- `dir = +1` (caresseur, foudroyeur, abreuvoir, dragofesse) : stat monte
|
||||
- `dir = -1` (baffeur) : stat descend
|
||||
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`
|
||||
|
||||
### Pour la mangeoire (XP) — même modèle que les autres jauges :
|
||||
|
||||
La mangeoire se vide exactement comme les autres jauges (même tiers, même dégression). L'XP suit le même algorithme :
|
||||
|
||||
1. XP gagnée = via `computeGaugeState` (tient compte des recharges et du gel à niveau 200)
|
||||
2. XP nécessaire = `xpForLevel(cible) - xpForLevel(départ)`
|
||||
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
|
||||
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
|
||||
|
||||
**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é)
|
||||
|
||||
---
|
||||
|
||||
## 8. Table d'XP et niveaux
|
||||
|
||||
### Table XP_RAW
|
||||
|
||||
Dictionnaire de 200 entrées : `niveau → XP cumulatif total`.
|
||||
|
||||
Exemples :
|
||||
- Niveau 1 → 0 XP
|
||||
- Niveau 10 → 809 XP
|
||||
- Niveau 50 → 34 365 XP
|
||||
- Niveau 100 → 172 668 XP
|
||||
- Niveau 200 → 867 582 XP
|
||||
|
||||
Les valeurs sont **cumulatives** (pas incrémentales).
|
||||
|
||||
### `xpForLevel(lvl)` : Retourne l'XP cumulatif pour atteindre le niveau `lvl`.
|
||||
|
||||
### `levelFromXp(xp)` : Retourne le plus haut niveau atteint avec `xp` points d'XP cumulatifs. Parcourt la table de 200 à 1 pour trouver le seuil.
|
||||
|
||||
---
|
||||
|
||||
## 9. ETA Sérénité — `calcSerenEta` / `calcSerenEtaLive`
|
||||
|
||||
### Version statique (`calcSerenEta`)
|
||||
1. Calcule `diff = cible - sérénité actuelle`
|
||||
2. Si `diff > 0` → besoin du caresseur (monte la sérénité)
|
||||
3. Si `diff < 0` → besoin du baffeur (baisse la sérénité)
|
||||
4. Temps = `timeToGain(niveauJauge, |diff|)`
|
||||
|
||||
### Version live (`calcSerenEtaLive`)
|
||||
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
|
||||
3. Calcule le temps restant avec les points encore à parcourir
|
||||
|
||||
---
|
||||
|
||||
## 10. ETA Niveau — `calcLevelEta` / `calcLevelEtaLive`
|
||||
|
||||
### Version statique (`calcLevelEta`)
|
||||
1. XP nécessaire = `xpForLevel(cible) - xpForLevel(niveauActuel)`
|
||||
2. Cible = `dd.levelTarget ?? 200`
|
||||
3. Temps = `timeToGain(niveauMangeoire, Math.min(xpNécessaire, niveauMangeoire))`
|
||||
|
||||
### Version live (`calcLevelEtaLive`)
|
||||
1. Cible = `dd.levelTarget ?? 200`
|
||||
2. XP gagnée via `computeGaugeState` (avec recharges et gel)
|
||||
3. Niveau estimé = `levelFromXp(xpDépart + xpGagnée)`
|
||||
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")
|
||||
|
||||
---
|
||||
|
||||
## 11. Countdown global d'un enclos — `enclosGlobalState(enc)`
|
||||
|
||||
**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`
|
||||
2. Prendre le **maximum** de tous les countdowns = le plus long à terminer
|
||||
3. Compter combien de DD ont TOUTES leurs cibles atteintes (`ddDone`)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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 ?"
|
||||
|
||||
### Principe : décomposition récursive par génération
|
||||
|
||||
Chaque race de génération ≥ 2 est produite par le croisement de 2 races parentes (table `BREEDING_RECIPES`).
|
||||
|
||||
**Algorithme** :
|
||||
1. On part de la race cible et de la quantité voulue
|
||||
2. Pour chaque génération (de la plus haute à gen 2) :
|
||||
- Pour chaque race nécessaire à cette génération :
|
||||
- Calculer le nombre de couples nécessaires (voir §14.1)
|
||||
- Ajouter les parents nécessaires (chacun × nombre de couples) dans le pool
|
||||
3. Les races gen 1 restantes = matières premières
|
||||
|
||||
### 14.1 Mécanisme du reproducteur
|
||||
|
||||
Un reproducteur est une DD réutilisable : elle peut faire plusieurs bébés.
|
||||
|
||||
```
|
||||
Si 2×R ≥ Q → couples = ceil(Q / 2)
|
||||
Sinon → couples = Q - R
|
||||
```
|
||||
|
||||
Où `R` = nombre de reproducteurs, `Q` = quantité nécessaire.
|
||||
|
||||
---
|
||||
|
||||
## 15. Calcul d'inventaire avec contraintes ♂/♀ — `calcInventaire()`
|
||||
|
||||
**Question** : "Avec mon stock actuel de DD (mâles et femelles), quels croisements puis-je réaliser ?"
|
||||
|
||||
### Modèle de données
|
||||
Chaque race dans l'inventaire : `{ m: mâles, f: femelles, n: neutres }`
|
||||
|
||||
### Algorithme : round-robin par génération
|
||||
|
||||
Pour chaque génération (2 → 10) :
|
||||
1. Lister tous les croisements possibles à cette génération
|
||||
2. **Boucle round-robin** : tant qu'au moins un croisement est possible :
|
||||
- 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)
|
||||
- Ajouter 1 neutre (`n++`) à la race du bébé produit
|
||||
|
||||
### Priorité de consommation
|
||||
```
|
||||
takeMale : si m > 0 → m--, sinon n--
|
||||
takeFemale : si f > 0 → f--, sinon n--
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. Constantes des stats
|
||||
|
||||
| Stat | Min | Max | Jauge(s) associée(s) |
|
||||
|-----------|--------|--------|----------------------|
|
||||
| Sérénité | -5 000 | 5 000 | Baffeur (↓), Caresseur (↑) |
|
||||
| Endurance | 0 | 20 000 | Foudroyeur (↑) |
|
||||
| Maturité | 0 | 20 000 | Abreuvoir (↑) |
|
||||
| Amour | 0 | 20 000 | Dragofesse (↑) |
|
||||
| Niveau/XP | 1 | 200 | Mangeoire (↑) |
|
||||
|
||||
---
|
||||
|
||||
## 17. Système de snapshots
|
||||
|
||||
Quand le timer démarre (démarrage initial uniquement, pas lors d'une reprise de pause) :
|
||||
- `snapGauges` : niveaux de toutes les jauges actives 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. 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.
|
||||
335
docs/fonctionnalites-par-ecran.md
Executable file
335
docs/fonctionnalites-par-ecran.md
Executable file
@ -0,0 +1,335 @@
|
||||
# Fonctionnalités par écran — Obsidienne
|
||||
|
||||
---
|
||||
|
||||
## 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 (0–100 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 (1–200)
|
||||
- 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 1–10)
|
||||
- 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 2–10 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 1–10)
|
||||
- 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
|
||||
2113
docs/plans/2026-03-27-ddd-architecture-plan.md
Executable file
2113
docs/plans/2026-03-27-ddd-architecture-plan.md
Executable file
File diff suppressed because it is too large
Load Diff
0
docs/plans/2026-03-27-v1.2.0-design.md
Normal file → Executable file
0
docs/plans/2026-03-27-v1.2.0-design.md
Normal file → Executable file
0
docs/plans/2026-03-27-v1.2.0-implementation.md
Normal file → Executable file
0
docs/plans/2026-03-27-v1.2.0-implementation.md
Normal file → Executable file
920
docs/plans/2026-04-04-accouplement-redesign.md
Executable file
920
docs/plans/2026-04-04-accouplement-redesign.md
Executable 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"
|
||||
```
|
||||
62
docs/plans/2026-04-04-electron-updater-design.md
Executable file
62
docs/plans/2026-04-04-electron-updater-design.md
Executable 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
|
||||
390
docs/plans/2026-04-04-electron-updater-implementation.md
Executable file
390
docs/plans/2026-04-04-electron-updater-implementation.md
Executable 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 |
|
||||
55
docs/plans/2026-04-05-toast-notifications-design.md
Executable file
55
docs/plans/2026-04-05-toast-notifications-design.md
Executable 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
|
||||
440
docs/plans/2026-04-05-toast-notifications-plan.md
Executable file
440
docs/plans/2026-04-05-toast-notifications-plan.md
Executable 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"
|
||||
```
|
||||
28
findings.md
28
findings.md
@ -1,28 +0,0 @@
|
||||
# Findings — Audit technique
|
||||
|
||||
## Variables CSS existantes (lignes 9-14)
|
||||
```
|
||||
--bg:#0b0b14 --bg2:#111120 --bg3:#181828 --bg4:#20203a
|
||||
--border:#2a2a45 --text:#dddaf8 --muted:#6868a0
|
||||
--ser:#c060ff --end:#f0bf30 --mat:#28c8f0 --amour:#ff5070 --xp:#ffa040
|
||||
--ok:#28e888 --warn:#ff9820 --r:10px
|
||||
```
|
||||
La majorité du CSS utilise déjà `var()`, mais il y a ~320 occurrences de couleurs inline dans le JS (rgb/rgba dans les templates littéraux). Les couleurs de jauges (--ser, --end, etc.) devront rester saturées même en mode clair.
|
||||
|
||||
## Progression enclos — données disponibles
|
||||
`enclosGlobalState(enc)` retourne `{globalMax, allDone, started, el, ddDone}`.
|
||||
- `ddDone / enc.dragodindes.length` = % progression simple
|
||||
- Fonctionne déjà, juste pas affiché visuellement sur les tabs
|
||||
|
||||
## Timer pause — détection retard
|
||||
- `enc.timer.pausedAt` = timestamp de mise en pause
|
||||
- `Date.now() - enc.timer.pausedAt` = durée de pause en ms
|
||||
- Seuil suggéré : 5 minutes (300 000 ms)
|
||||
|
||||
## Raccourcis clavier
|
||||
- Aucun listener keyboard existant
|
||||
- Attention aux inputs focus : vérifier `document.activeElement.tagName !== 'INPUT'`
|
||||
|
||||
## Animations
|
||||
- Transitions CSS existent sur tabs, gauges, pills (partielles)
|
||||
- Aucune transition de contenu (#enclos-content) lors du changement d'onglet
|
||||
BIN
icon.ico
Executable file
BIN
icon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
0
icon.png
Normal file → Executable file
0
icon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
422
main.js
422
main.js
@ -1,422 +0,0 @@
|
||||
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, Notification, dialog, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
// ─── NOM DE L'APPLICATION ─────────────────────────────────────────────────────
|
||||
app.setName('Minuteur Dragodinde');
|
||||
// Windows utilise l'AppUserModelId pour le nom affiché dans les notifications
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('Minuteur Dragodinde');
|
||||
}
|
||||
|
||||
// ─── MODE DEV ────────────────────────────────────────────────────────────────
|
||||
// En dev (npm start), les données sont isolées de l'app installée
|
||||
if (!app.isPackaged) {
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'MinuteurDragodinde-DEV'));
|
||||
}
|
||||
|
||||
// ─── CONFIG GITEA ─────────────────────────────────────────────────────────────
|
||||
const GITEA_HOST = 'gitea.mickael-pol.fr'; // ton instance Gitea
|
||||
const GITEA_USER = 'mickael'; // ton user Gitea
|
||||
const GITEA_REPO = 'dd-timer'; // ton repo
|
||||
const CURRENT_VERSION = app.getVersion(); // lu depuis package.json
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
let isQuitting = false;
|
||||
let updateInfo = null; // { version, downloadUrl } si mise à jour dispo
|
||||
let downloading = false;
|
||||
|
||||
// ─── ICÔNE ───────────────────────────────────────────────────────────────────
|
||||
function getTrayIcon() {
|
||||
const fs = require('fs');
|
||||
const iconPath = path.join(__dirname, 'icon.png');
|
||||
if (fs.existsSync(iconPath)) return nativeImage.createFromPath(iconPath);
|
||||
return nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
// ─── FENÊTRE ─────────────────────────────────────────────────────────────────
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280, height: 900, minWidth: 960, minHeight: 650,
|
||||
title: 'Minuteur Dragodinde - Dofus 3',
|
||||
backgroundColor: '#0b0b14',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
mainWindow.loadFile(path.join(__dirname, 'src', 'index.html'));
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting) {
|
||||
e.preventDefault();
|
||||
const choice = dialog.showMessageBoxSync(mainWindow, {
|
||||
type: 'question',
|
||||
buttons: ['Minimiser', 'Quitter'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Minuteur Dragodinde',
|
||||
message: 'Que souhaites-tu faire ?',
|
||||
detail: 'Minimiser garde l\'app en arriere-plan.\nLes alarmes continueront de sonner.',
|
||||
});
|
||||
if (choice === 1) {
|
||||
if (tray) { tray.destroy(); tray = null; }
|
||||
process.exit(0);
|
||||
} else {
|
||||
mainWindow.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Envoyer les infos de version au renderer une fois chargé
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow.webContents.send('app-version', CURRENT_VERSION);
|
||||
if (updateInfo) {
|
||||
mainWindow.webContents.send('update-available', updateInfo);
|
||||
}
|
||||
// Badge DEV visible dans l'interface
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.executeJavaScript(`
|
||||
const p = document.querySelector('header p');
|
||||
if (p && !document.getElementById('dev-badge')) {
|
||||
const b = document.createElement('span');
|
||||
b.id = 'dev-badge';
|
||||
b.textContent = 'DEV';
|
||||
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
|
||||
p.appendChild(b);
|
||||
}
|
||||
`).catch(()=>{});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── TRAY ─────────────────────────────────────────────────────────────────────
|
||||
function createTray() {
|
||||
tray = new Tray(getTrayIcon());
|
||||
tray.setToolTip(`Minuteur Dragodinde v${CURRENT_VERSION}`);
|
||||
rebuildTrayMenu();
|
||||
tray.on('double-click', () => { mainWindow.show(); mainWindow.focus(); });
|
||||
}
|
||||
|
||||
function rebuildTrayMenu() {
|
||||
if (!tray) return;
|
||||
const items = [
|
||||
{ label: `Minuteur Dragodinde v${CURRENT_VERSION}`, enabled: false },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Ouvrir', click: () => { mainWindow.show(); mainWindow.focus(); } },
|
||||
];
|
||||
if (updateInfo) {
|
||||
items.push({ type: 'separator' });
|
||||
items.push({
|
||||
label: `⬆ Mise a jour v${updateInfo.version} disponible !`,
|
||||
click: () => startDownload(),
|
||||
});
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: 'Quitter', click: () => { isQuitting = true; app.quit(); } });
|
||||
tray.setContextMenu(Menu.buildFromTemplate(items));
|
||||
}
|
||||
|
||||
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
|
||||
function fireNotification(title, body) {
|
||||
if (!Notification.isSupported()) return;
|
||||
const n = new Notification({ title, body, timeoutType: 'never' });
|
||||
n.on('click', () => { mainWindow.show(); mainWindow.focus(); });
|
||||
n.show();
|
||||
}
|
||||
|
||||
// ─── IPC ──────────────────────────────────────────────────────────────────────
|
||||
ipcMain.on('trigger-alarm', (event, { enclosName }) => {
|
||||
fireNotification('Dragodindes pretes !', enclosName + ' - Toutes les cibles ont ete atteintes !');
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('play-alarm-sound');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('show-notification', (event, { title, body }) => {
|
||||
fireNotification(title, body);
|
||||
});
|
||||
|
||||
// Dialogue de confirmation natif (remplace confirm() du renderer qui casse les inputs)
|
||||
ipcMain.handle('show-confirm', (event, { title, message, detail }) => {
|
||||
const choice = dialog.showMessageBoxSync(mainWindow, {
|
||||
type: 'question',
|
||||
buttons: ['Annuler', 'Confirmer'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: title || 'Confirmation',
|
||||
message: message || '',
|
||||
detail: detail || '',
|
||||
});
|
||||
return choice === 1;
|
||||
});
|
||||
|
||||
// ─── SAUVEGARDE FICHIER (persistante entre mises à jour) ─────────────────
|
||||
const dataFile = path.join(app.getPath('userData'), 'dd-timer-data.json');
|
||||
|
||||
ipcMain.handle('load-data', () => {
|
||||
try {
|
||||
if (fs.existsSync(dataFile)) return fs.readFileSync(dataFile, 'utf-8');
|
||||
} catch (e) { console.error('load-data error:', e.message); }
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on('save-data', (event, json) => {
|
||||
try {
|
||||
fs.writeFileSync(dataFile, json, 'utf-8');
|
||||
} catch (e) { console.error('save-data error:', e.message); }
|
||||
});
|
||||
|
||||
// ─── NTFY (notifications mobiles) ─────────────────────────────────────────
|
||||
ipcMain.on('send-ntfy', (event, { url, title, message }) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
const mod = parsed.protocol === 'https:' ? https : require('http');
|
||||
const postData = message;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(postData, 'utf-8'),
|
||||
'Title': title,
|
||||
'Priority': 'high',
|
||||
'Tags': 'hatching_chick',
|
||||
},
|
||||
};
|
||||
const req = mod.request(options, (res) => {
|
||||
res.on('data', () => {}); // drain
|
||||
res.on('end', () => {});
|
||||
});
|
||||
req.on('error', (e) => console.warn('ntfy send error:', e.message));
|
||||
req.write(postData, 'utf-8');
|
||||
req.end();
|
||||
} catch (e) {
|
||||
console.warn('ntfy error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('focus-window', () => { mainWindow.show(); mainWindow.focus(); });
|
||||
|
||||
// Renderer demande à installer la mise à jour
|
||||
ipcMain.on('install-update', () => startDownload());
|
||||
|
||||
// Renderer demande la version
|
||||
ipcMain.handle('get-version', () => CURRENT_VERSION);
|
||||
|
||||
// ─── VÉRIFICATION DE MISE À JOUR ─────────────────────────────────────────────
|
||||
function compareVersions(a, b) {
|
||||
// Retourne > 0 si b > a (b est plus récent)
|
||||
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;
|
||||
}
|
||||
|
||||
function checkForUpdates(silent = false) {
|
||||
// API Gitea : GET /api/v1/repos/{user}/{repo}/releases?limit=1
|
||||
// Retourne un tableau — le premier élément est la release la plus récente
|
||||
const options = {
|
||||
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 => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const releases = JSON.parse(data);
|
||||
|
||||
// Gitea renvoie un tableau, on prend le premier (le plus récent)
|
||||
if (!Array.isArray(releases) || releases.length === 0) return;
|
||||
const release = releases[0];
|
||||
|
||||
const latestVersion = release.tag_name;
|
||||
if (!latestVersion) return;
|
||||
|
||||
if (compareVersions(CURRENT_VERSION, latestVersion) > 0) {
|
||||
// Chercher l'asset installeur (.exe contenant "Setup")
|
||||
const asset = release.assets && release.assets.find(a =>
|
||||
a.name.includes('Setup') && a.name.endsWith('.exe')
|
||||
);
|
||||
if (!asset) return;
|
||||
|
||||
updateInfo = {
|
||||
version: latestVersion,
|
||||
downloadUrl: asset.browser_download_url,
|
||||
assetName: asset.name,
|
||||
releaseNotes: release.body || '',
|
||||
};
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-available', updateInfo);
|
||||
}
|
||||
|
||||
fireNotification(
|
||||
`Mise a jour v${latestVersion} disponible !`,
|
||||
'Cliquez pour mettre a jour Minuteur Dragodinde.'
|
||||
);
|
||||
|
||||
rebuildTrayMenu();
|
||||
|
||||
} else if (!silent) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-not-available');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Update check parse error:', e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => console.error('Update check error:', e.message));
|
||||
req.end();
|
||||
}
|
||||
|
||||
// ─── TÉLÉCHARGEMENT ET REMPLACEMENT ──────────────────────────────────────────
|
||||
function startDownload() {
|
||||
if (!updateInfo || downloading) return;
|
||||
downloading = true;
|
||||
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-downloading', { version: updateInfo.version });
|
||||
}
|
||||
|
||||
const tmpDir = os.tmpdir();
|
||||
const tmpExe = path.join(tmpDir, updateInfo.assetName);
|
||||
const file = fs.createWriteStream(tmpExe);
|
||||
const currentExe = process.execPath;
|
||||
|
||||
// Suivre les redirections manuellement (GitHub assets redirigent)
|
||||
function download(url, redirectCount = 0) {
|
||||
if (redirectCount > 5) {
|
||||
sendUpdateError('Trop de redirections.');
|
||||
return;
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const mod = urlObj.protocol === 'https:' ? https : require('http');
|
||||
const opts = {
|
||||
hostname: urlObj.hostname,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': `MinuteurDragodinde/${CURRENT_VERSION}` },
|
||||
};
|
||||
|
||||
mod.request(opts, (res) => {
|
||||
if (res.statusCode === 302 || res.statusCode === 301) {
|
||||
download(res.headers.location, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
sendUpdateError(`Erreur HTTP ${res.statusCode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const total = parseInt(res.headers['content-length'] || '0', 10);
|
||||
let received = 0;
|
||||
|
||||
res.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
file.write(chunk);
|
||||
if (total > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const pct = Math.round((received / total) * 100);
|
||||
mainWindow.webContents.send('update-progress', { percent: pct });
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
file.end();
|
||||
file.on('finish', () => {
|
||||
// Lancer le script de remplacement
|
||||
launchUpdater(tmpExe, currentExe);
|
||||
});
|
||||
});
|
||||
|
||||
res.on('error', e => sendUpdateError(e.message));
|
||||
}).on('error', e => sendUpdateError(e.message)).end();
|
||||
}
|
||||
|
||||
download(updateInfo.downloadUrl);
|
||||
}
|
||||
|
||||
function sendUpdateError(msg) {
|
||||
downloading = false;
|
||||
console.error('Update error:', msg);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-error', { message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
function launchUpdater(newExe, currentExe) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-ready');
|
||||
}
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Script batch qui survit à la fermeture de l'app :
|
||||
// 1. Attend que l'app se ferme
|
||||
// 2. Lance l'installeur en silencieux
|
||||
// 3. Relance l'app
|
||||
// 4. Se supprime lui-même
|
||||
const batPath = path.join(os.tmpdir(), 'dd-timer-update.cmd');
|
||||
const batContent = [
|
||||
'@echo off',
|
||||
'timeout /t 3 /nobreak >nul',
|
||||
`start /wait "" "${newExe}" /S`,
|
||||
'timeout /t 5 /nobreak >nul',
|
||||
`start "" "${currentExe}"`,
|
||||
'del "%~f0"',
|
||||
].join('\r\n');
|
||||
|
||||
fs.writeFileSync(batPath, batContent, 'utf-8');
|
||||
|
||||
setTimeout(() => {
|
||||
spawn('cmd.exe', ['/c', batPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
}).unref();
|
||||
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ─── CYCLE DE VIE ────────────────────────────────────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
// Vérifier les mises à jour uniquement au démarrage (silencieux)
|
||||
setTimeout(() => checkForUpdates(true), 3000);
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => { isQuitting = true; });
|
||||
4
ntfy-redirect/index.html
Normal file → Executable file
4
ntfy-redirect/index.html
Normal file → Executable file
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Minuteur Dragodinde - Notifications</title>
|
||||
<title>Obsidienne - Notifications</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0b0b14;color:#dddaf8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
@ -23,7 +23,7 @@ h1{font-size:1.3rem;margin-bottom:8px}
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Minuteur Dragodinde</h1>
|
||||
<h1>Obsidienne</h1>
|
||||
<p class="sub">Notifications mobiles</p>
|
||||
|
||||
<div id="loading">
|
||||
|
||||
2270
package-lock.json
generated
Normal file → Executable file
2270
package-lock.json
generated
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
54
package.json
Normal file → Executable file
54
package.json
Normal file → Executable file
@ -1,24 +1,32 @@
|
||||
{
|
||||
"name": "minuteur-dragodinde",
|
||||
"version": "1.1.5",
|
||||
"description": "Minuteur elevage Dragodinde Dofus 3",
|
||||
"main": "main.js",
|
||||
"name": "obsidienne",
|
||||
"version": "1.1.7",
|
||||
"description": "Obsidienne — Minuteur d'élevage Dragodinde pour Dofus 3",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "Mickael",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build": "electron-builder --win --x64"
|
||||
"dev": "vite",
|
||||
"build": "vite build && electron-builder --win --x64",
|
||||
"start": "npm run dev",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "npx playwright test"
|
||||
},
|
||||
"build": {
|
||||
"appId": "fr.mickael-pol.minuteur-dragodinde",
|
||||
"productName": "Minuteur Dragodinde",
|
||||
"appId": "fr.mickael-pol.obsidienne",
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer/releases/download/latest"
|
||||
},
|
||||
"productName": "Obsidienne",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*",
|
||||
"icon.png"
|
||||
"dist-vite/**/*",
|
||||
"dist-electron/**/*",
|
||||
"icon.ico"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
@ -31,7 +39,7 @@
|
||||
],
|
||||
"sign": null,
|
||||
"signingHashAlgorithms": [],
|
||||
"icon": "icon.png",
|
||||
"icon": "icon.ico",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"nsis": {
|
||||
@ -40,18 +48,32 @@
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Minuteur Dragodinde",
|
||||
"shortcutName": "Obsidienne",
|
||||
"guid": "3b4a21ac-02a8-4525-a48b-988079fc75d4",
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"runAfterFinish": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"electron": "32.2.7",
|
||||
"electron-builder": "24.13.3"
|
||||
"electron-builder": "24.13.3",
|
||||
"esbuild": "^0.27.4",
|
||||
"happy-dom": "^20.8.9",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.mickael-pol.fr/mickael/dd-timer.git"
|
||||
},
|
||||
"productName": "Minuteur Dragodinde"
|
||||
"productName": "Obsidienne",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
}
|
||||
}
|
||||
11
playwright.config.ts
Executable file
11
playwright.config.ts
Executable file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
30
preload.js
30
preload.js
@ -1,30 +0,0 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
isElectron: true,
|
||||
|
||||
// Sauvegarde persistante (fichier JSON dans userData)
|
||||
saveData: (json) => ipcRenderer.send('save-data', json),
|
||||
loadData: () => ipcRenderer.invoke('load-data'),
|
||||
|
||||
// Alarme
|
||||
triggerAlarm: (enclosName) => ipcRenderer.send('trigger-alarm', { enclosName }),
|
||||
showNotification: (title, body) => ipcRenderer.send('show-notification', { title, body }),
|
||||
sendNtfy: (url, title, message) => ipcRenderer.send('send-ntfy', { url, title, message }),
|
||||
focusWindow: () => ipcRenderer.send('focus-window'),
|
||||
showConfirm: (title, message, detail) => ipcRenderer.invoke('show-confirm', { title, message, detail }),
|
||||
onPlayAlarmSound: (cb) => ipcRenderer.on('play-alarm-sound', () => cb()),
|
||||
|
||||
// Version
|
||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||
onAppVersion: (cb) => ipcRenderer.on('app-version', (e, v) => cb(v)),
|
||||
|
||||
// Mises à jour
|
||||
installUpdate: () => ipcRenderer.send('install-update'),
|
||||
onUpdateAvailable: (cb) => ipcRenderer.on('update-available', (e, info) => cb(info)),
|
||||
onUpdateNotAvailable: (cb) => ipcRenderer.on('update-not-available', () => cb()),
|
||||
onUpdateDownloading: (cb) => ipcRenderer.on('update-downloading', (e, info) => cb(info)),
|
||||
onUpdateProgress: (cb) => ipcRenderer.on('update-progress', (e, info) => cb(info)),
|
||||
onUpdateReady: (cb) => ipcRenderer.on('update-ready', () => cb()),
|
||||
onUpdateError: (cb) => ipcRenderer.on('update-error', (e, info) => cb(info)),
|
||||
});
|
||||
12
progress.md
12
progress.md
@ -1,12 +0,0 @@
|
||||
# Progress Log
|
||||
|
||||
## Session 2026-03-26
|
||||
- [x] Audit technique terminé
|
||||
- [x] Plan rédigé et soumis à validation
|
||||
- [ ] Phase 1.1 — Tooltips pills
|
||||
- [ ] Phase 1.2 — Indication retard
|
||||
- [ ] Phase 2.1 — Barre progression tabs
|
||||
- [ ] Phase 2.2 — Animations transition
|
||||
- [ ] Phase 4.1 — Raccourcis clavier
|
||||
- [ ] Phase 3.1 — Thème clair/sombre
|
||||
- [ ] Phase 5.1 — Planificateur journalier
|
||||
13
src/application/commands/AddDragodinde.ts
Executable file
13
src/application/commands/AddDragodinde.ts
Executable file
@ -0,0 +1,13 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { addDragodinde } from '@domain/entities/Enclos';
|
||||
|
||||
export interface AddDragodindeCommand { type: 'add-dragodinde'; enclosId: number; }
|
||||
|
||||
export function createAddDragodindeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: AddDragodindeCommand): void => {
|
||||
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
|
||||
if (idx < 0) return;
|
||||
state.enclos[idx] = addDragodinde(state.enclos[idx]!);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
52
src/application/commands/CompleteTimer.ts
Executable file
52
src/application/commands/CompleteTimer.ts
Executable file
@ -0,0 +1,52 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { EventBus } from '@domain/events/EventBus';
|
||||
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { computeGaugeState } from '@domain/services/GaugeCalculator';
|
||||
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
|
||||
|
||||
export interface CompleteTimerCommand { type: 'complete-timer'; enclosId: number; }
|
||||
|
||||
export function createCompleteTimerHandler(state: AppState, repo: StateRepository, events: EventBus) {
|
||||
return (cmd: CompleteTimerCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || !enc.timer.running || enc.alerted['__done__']) return;
|
||||
|
||||
const now = Date.now();
|
||||
enc.timer.running = false;
|
||||
enc.timer.pausedAt = now;
|
||||
enc.alerted['__done__'] = true;
|
||||
|
||||
// Persister les stats finales de chaque DD dans dd.stats
|
||||
// pour que la session suivante parte des bonnes valeurs.
|
||||
const el = (now - enc.timer.startTime! - enc.timer.pausedMs) / 1000;
|
||||
for (const dd of enc.dragodindes) {
|
||||
for (const gid of enc.activeGauges) {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
const startGl = enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid];
|
||||
const startSt = (enc.timer.snapStats[dd.id]?.[def.stat] ?? (dd.stats as Record<string, number>)[def.stat]) as number;
|
||||
const recharges = enc.timer.gaugeRecharges[gid] ?? [];
|
||||
|
||||
let pts: number;
|
||||
if (def.isXp) {
|
||||
pts = Math.max(0, xpForLevel(200) - xpForLevel(startSt));
|
||||
} else {
|
||||
const sd = STAT_DEFS[def.stat];
|
||||
pts = def.dir > 0 ? Math.max(0, sd.max - startSt) : Math.max(0, startSt - sd.min);
|
||||
}
|
||||
|
||||
const { gained } = computeGaugeState(startGl, recharges, pts, el);
|
||||
|
||||
if (def.isXp) {
|
||||
dd.stats.xp = Math.min(200, Math.max(1, levelFromXp(xpForLevel(startSt) + gained)));
|
||||
} else {
|
||||
const sd = STAT_DEFS[def.stat];
|
||||
const raw = startSt + def.dir * gained;
|
||||
(dd.stats as Record<string, number>)[def.stat] = Math.min(sd.max, Math.max(sd.min, Math.round(raw)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repo.save(state);
|
||||
events.emit({ type: 'timer-completed', enclosName: enc.name });
|
||||
};
|
||||
}
|
||||
24
src/application/commands/CreateEnclos.ts
Executable file
24
src/application/commands/CreateEnclos.ts
Executable file
@ -0,0 +1,24 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createEnclos, addDragodinde, MAX_ENCLOS } from '@domain/entities/Enclos';
|
||||
|
||||
export interface CreateEnclosCommand { type: 'create-enclos'; }
|
||||
|
||||
export function createCreateEnclosHandler(state: AppState, repo: StateRepository) {
|
||||
return (_cmd: CreateEnclosCommand): void => {
|
||||
if (state.enclos.length >= MAX_ENCLOS) return;
|
||||
// Gap-filling : premier numéro de slot (1..MAX_ENCLOS) absent des noms existants "Enclos N"
|
||||
const usedSlots = new Set<number>();
|
||||
for (const e of state.enclos) {
|
||||
const m = e.name.match(/^Enclos (\d+)$/);
|
||||
if (m) usedSlots.add(Number(m[1]));
|
||||
}
|
||||
let slot = 1;
|
||||
while (usedSlots.has(slot)) slot++;
|
||||
let enc = createEnclos(state.nextEnclosId, `Enclos ${slot}`);
|
||||
enc = addDragodinde(enc); // Always start with 1 DD
|
||||
state.enclos.push(enc);
|
||||
state.nextEnclosId++;
|
||||
state.activeId = enc.id;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
17
src/application/commands/DeleteEnclos.ts
Executable file
17
src/application/commands/DeleteEnclos.ts
Executable file
@ -0,0 +1,17 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { EventBus } from '@domain/events/EventBus';
|
||||
|
||||
export interface DeleteEnclosCommand { type: 'delete-enclos'; enclosId: number; }
|
||||
|
||||
export function createDeleteEnclosHandler(state: AppState, repo: StateRepository, events: EventBus) {
|
||||
return (cmd: DeleteEnclosCommand): void => {
|
||||
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
|
||||
if (idx < 0) return;
|
||||
state.enclos.splice(idx, 1);
|
||||
if (state.activeId === cmd.enclosId) {
|
||||
state.activeId = state.enclos.length > 0 ? state.enclos[0]!.id : 'dashboard';
|
||||
}
|
||||
events.emit({ type: 'enclos-deleted', enclosId: cmd.enclosId });
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/DeleteWorkflow.ts
Executable file
13
src/application/commands/DeleteWorkflow.ts
Executable file
@ -0,0 +1,13 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface DeleteWorkflowCommand {
|
||||
type: 'delete-workflow';
|
||||
workflowId: number;
|
||||
}
|
||||
|
||||
export function createDeleteWorkflowHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: DeleteWorkflowCommand): void => {
|
||||
state.workflows = (state.workflows as { id: number }[]).filter(w => w.id !== cmd.workflowId);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
65
src/application/commands/DragodindeActions.ts
Executable file
65
src/application/commands/DragodindeActions.ts
Executable file
@ -0,0 +1,65 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { StatType } from '@domain/value-objects/GaugeType';
|
||||
|
||||
export interface RenameDragodindeCommand { type: 'rename-dragodinde'; enclosId: number; ddId: number; name: string; }
|
||||
export interface UpdateDdStatCommand { type: 'update-dd-stat'; enclosId: number; ddId: number; stat: StatType; value: number; }
|
||||
export interface UpdateDdSerenTargetCmd { type: 'update-dd-seren-target'; enclosId: number; ddId: number; target: number | null; }
|
||||
export interface UpdateDdLevelTargetCmd { type: 'update-dd-level-target'; enclosId: number; ddId: number; target: number | null; }
|
||||
export interface ReorderDragodindeCommand { type: 'reorder-dragodinde'; enclosId: number; fromDdId: number; toDdId: number; }
|
||||
|
||||
export function createRenameDragodindeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: RenameDragodindeCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
|
||||
if (!dd || !cmd.name.trim()) return;
|
||||
dd.name = cmd.name.trim();
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateDdStatHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateDdStatCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
|
||||
if (!dd) return;
|
||||
dd.stats[cmd.stat] = cmd.value;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateDdSerenTargetHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateDdSerenTargetCmd): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
|
||||
if (!dd) return;
|
||||
dd.sereniteTarget = cmd.target;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateDdLevelTargetHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateDdLevelTargetCmd): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
const dd = enc.dragodindes.find(d => d.id === cmd.ddId);
|
||||
if (!dd) return;
|
||||
dd.levelTarget = cmd.target;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createReorderDragodindeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ReorderDragodindeCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
const fromIdx = enc.dragodindes.findIndex(d => d.id === cmd.fromDdId);
|
||||
const toIdx = enc.dragodindes.findIndex(d => d.id === cmd.toDdId);
|
||||
if (fromIdx < 0 || toIdx < 0) return;
|
||||
const [item] = enc.dragodindes.splice(fromIdx, 1);
|
||||
enc.dragodindes.splice(toIdx, 0, item!);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
56
src/application/commands/EnclosActions.ts
Executable file
56
src/application/commands/EnclosActions.ts
Executable file
@ -0,0 +1,56 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createDragodinde } from '@domain/entities/Dragodinde';
|
||||
|
||||
export interface ClearEnclosCommand { type: 'clear-enclos'; enclosId: number; }
|
||||
export interface RenameEnclosCommand { type: 'rename-enclos'; enclosId: number; name: string; }
|
||||
export interface ResetTimerCommand { type: 'reset-timer'; enclosId: number; }
|
||||
export interface NouvelleFourneeCommand { type: 'nouvelle-fournee'; enclosId: number; }
|
||||
|
||||
export function createClearEnclosHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ClearEnclosCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
enc.dragodindes = [];
|
||||
enc.nextDdId = 1;
|
||||
enc.activeGauges = [];
|
||||
enc.gaugeLevels = { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 };
|
||||
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
|
||||
enc.alerted = {};
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createRenameEnclosHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: RenameEnclosCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || !cmd.name.trim()) return;
|
||||
enc.name = cmd.name.trim();
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetTimerHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ResetTimerCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
|
||||
enc.alerted = {};
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createNouvelleFourneeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: NouvelleFourneeCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
// Reset timer et état de session
|
||||
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} };
|
||||
enc.alerted = {};
|
||||
// Remet les niveaux de jauges à 0
|
||||
enc.gaugeLevels = { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 };
|
||||
// Vide toutes les DDs et en ajoute une nouvelle avec les stats de base
|
||||
enc.dragodindes = [createDragodinde(1)];
|
||||
enc.nextDdId = 2;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
25
src/application/commands/ImportWorkflows.ts
Executable file
25
src/application/commands/ImportWorkflows.ts
Executable file
@ -0,0 +1,25 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { WorkflowItem } from '@application/queries/GetWorkflows';
|
||||
|
||||
export interface ImportWorkflowsCommand {
|
||||
type: 'import-workflows';
|
||||
workflows: WorkflowItem[];
|
||||
}
|
||||
|
||||
export function createImportWorkflowsHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ImportWorkflowsCommand): void => {
|
||||
const existing = state.workflows as WorkflowItem[];
|
||||
const existingIds = new Set(existing.map(w => w.id));
|
||||
|
||||
for (const wf of cmd.workflows) {
|
||||
if (existingIds.has(wf.id)) {
|
||||
// Réattribuer un nouvel id pour éviter les doublons
|
||||
wf.id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
}
|
||||
existing.push(wf);
|
||||
existingIds.add(wf.id);
|
||||
}
|
||||
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
35
src/application/commands/RechargeGauge.ts
Executable file
35
src/application/commands/RechargeGauge.ts
Executable file
@ -0,0 +1,35 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
import { elapsed } from '@domain/services/GaugeCalculator';
|
||||
|
||||
export interface RechargeGaugeCommand {
|
||||
type: 'recharge-gauge';
|
||||
enclosId: number;
|
||||
gaugeId: GaugeType;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function createRechargeGaugeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: RechargeGaugeCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || !enc.timer.startTime) return;
|
||||
|
||||
const atSec = elapsed(enc.timer);
|
||||
if (!enc.timer.gaugeRecharges[cmd.gaugeId]) enc.timer.gaugeRecharges[cmd.gaugeId] = [];
|
||||
|
||||
// Consolider : si la dernière recharge est à moins de 5s, la remplacer
|
||||
// (évite de polluer le tableau lors de la saisie en temps réel)
|
||||
const arr = enc.timer.gaugeRecharges[cmd.gaugeId];
|
||||
const last = arr.length > 0 ? arr[arr.length - 1] : null;
|
||||
if (last && Math.abs(atSec - last.atSec) < 2) {
|
||||
last.atSec = atSec;
|
||||
last.level = cmd.level;
|
||||
} else {
|
||||
arr.push({ atSec, level: cmd.level });
|
||||
}
|
||||
|
||||
// Mettre à jour gaugeLevels pour l'affichage de l'input
|
||||
enc.gaugeLevels[cmd.gaugeId] = cmd.level;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
23
src/application/commands/RegisterAccouplement.ts
Executable file
23
src/application/commands/RegisterAccouplement.ts
Executable file
@ -0,0 +1,23 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { createAccouplement } from '@domain/entities/Accouplement';
|
||||
import type { EventBus } from '@domain/events/EventBus';
|
||||
import { RACE_GEN } from '@domain/value-objects/Race';
|
||||
|
||||
export interface RegisterAccouplementCommand {
|
||||
type: 'register-accouplement';
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
baby: string;
|
||||
couples: number;
|
||||
babiesObtained: number;
|
||||
}
|
||||
|
||||
export function createRegisterAccouplementHandler(state: AppState, repo: StateRepository, events: EventBus) {
|
||||
return (cmd: RegisterAccouplementCommand): void => {
|
||||
const gen = RACE_GEN[cmd.baby] ?? 0;
|
||||
const acc = createAccouplement(cmd.parentA, cmd.parentB, cmd.baby, gen, cmd.couples, cmd.babiesObtained);
|
||||
state.accouplements.push(acc);
|
||||
events.emit({ type: 'accouplement-registered', accouplement: acc });
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/RemoveDragodinde.ts
Executable file
13
src/application/commands/RemoveDragodinde.ts
Executable file
@ -0,0 +1,13 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { removeDragodinde } from '@domain/entities/Enclos';
|
||||
|
||||
export interface RemoveDragodindeCommand { type: 'remove-dragodinde'; enclosId: number; ddId: number; }
|
||||
|
||||
export function createRemoveDragodindeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: RemoveDragodindeCommand): void => {
|
||||
const idx = state.enclos.findIndex(e => e.id === cmd.enclosId);
|
||||
if (idx < 0) return;
|
||||
state.enclos[idx] = removeDragodinde(state.enclos[idx]!, cmd.ddId);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
17
src/application/commands/ReorderEnclos.ts
Executable file
17
src/application/commands/ReorderEnclos.ts
Executable file
@ -0,0 +1,17 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface ReorderEnclosCommand {
|
||||
type: 'reorder-enclos';
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}
|
||||
|
||||
export function createReorderEnclosHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ReorderEnclosCommand): void => {
|
||||
if (cmd.fromIndex < 0 || cmd.toIndex < 0) return;
|
||||
if (cmd.fromIndex >= state.enclos.length || cmd.toIndex >= state.enclos.length) return;
|
||||
const [moved] = state.enclos.splice(cmd.fromIndex, 1);
|
||||
if (moved) state.enclos.splice(cmd.toIndex, 0, moved);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
11
src/application/commands/ResetStats.ts
Executable file
11
src/application/commands/ResetStats.ts
Executable file
@ -0,0 +1,11 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface ResetStatsCommand { type: 'reset-stats'; }
|
||||
|
||||
export function createResetStatsHandler(state: AppState, repo: StateRepository) {
|
||||
return (_cmd: ResetStatsCommand): void => {
|
||||
state.archivedStats = [];
|
||||
state.accouplements = [];
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
56
src/application/commands/SaveWorkflow.ts
Executable file
56
src/application/commands/SaveWorkflow.ts
Executable file
@ -0,0 +1,56 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { WorkflowItem } from '@application/queries/GetWorkflows';
|
||||
|
||||
export interface SaveWorkflowCommand {
|
||||
type: 'save-workflow';
|
||||
target: string;
|
||||
qty: number;
|
||||
materials: { race: string; m: number; f: number }[];
|
||||
steps: { baby: string; parentA: string; parentB: string; couples: number; gen: number }[];
|
||||
repro: Record<string, number>;
|
||||
}
|
||||
|
||||
export function createSaveWorkflowHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: SaveWorkflowCommand): void => {
|
||||
const workflows = state.workflows as WorkflowItem[];
|
||||
|
||||
// Group steps by gen (already sorted gen2 → genN after calcAppro reverse)
|
||||
const genMap = new Map<number, typeof cmd.steps>();
|
||||
for (const step of cmd.steps) {
|
||||
if (!genMap.has(step.gen)) genMap.set(step.gen, []);
|
||||
genMap.get(step.gen)!.push(step);
|
||||
}
|
||||
|
||||
const wfSteps = Array.from(genMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([gen, steps]) => ({
|
||||
gen,
|
||||
crossings: steps.map(s => ({
|
||||
race: s.baby,
|
||||
needed: s.couples,
|
||||
parentA: s.parentA,
|
||||
parentB: s.parentB,
|
||||
couples: s.couples,
|
||||
repro: cmd.repro[s.baby] ?? 0,
|
||||
done: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
const wf: WorkflowItem = {
|
||||
id: Date.now(),
|
||||
name: `${cmd.target} ×${cmd.qty}`,
|
||||
target: cmd.target,
|
||||
qty: cmd.qty,
|
||||
createdAt: Date.now(),
|
||||
materials: cmd.materials.map(m => ({
|
||||
name: m.race,
|
||||
needed: m.m + m.f,
|
||||
done: 0,
|
||||
})),
|
||||
steps: wfSteps,
|
||||
};
|
||||
|
||||
workflows.push(wf);
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
59
src/application/commands/StartTimer.ts
Executable file
59
src/application/commands/StartTimer.ts
Executable file
@ -0,0 +1,59 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { xpForLevel } from '@domain/value-objects/XpTable';
|
||||
|
||||
export interface StartTimerCommand { type: 'start-timer'; enclosId: number; }
|
||||
|
||||
export function createStartTimerHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: StartTimerCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || enc.timer.running) return;
|
||||
if (!enc.activeGauges.length || !enc.dragodindes.length) return;
|
||||
|
||||
// Ne pas démarrer si toutes les jauges actives sont à 0 (aucun point ne sera gagné)
|
||||
const allGaugesEmpty = enc.activeGauges.every(gid => (enc.gaugeLevels[gid] ?? 0) <= 0);
|
||||
if (allGaugesEmpty) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (enc.timer.startTime !== null && enc.timer.pausedAt !== null && !enc.alerted['__done__']) {
|
||||
// Reprise depuis une pause manuelle : accumuler le temps pausé
|
||||
enc.timer.pausedMs += now - enc.timer.pausedAt;
|
||||
enc.timer.pausedAt = null;
|
||||
enc.timer.running = true;
|
||||
} else {
|
||||
// Démarrage initial — vérifier qu'au moins une cible n'est pas déjà atteinte
|
||||
const allTargetsAlreadyMet = enc.dragodindes.every(dd => {
|
||||
return enc.activeGauges.every(gid => {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
const stat = (dd.stats as Record<string, number>)[def.stat] as number;
|
||||
if (def.isXp) {
|
||||
const target = dd.levelTarget ?? 200;
|
||||
return stat >= target;
|
||||
}
|
||||
const sd = STAT_DEFS[def.stat];
|
||||
if (def.stat === 'serenite' && dd.sereniteTarget !== null && dd.sereniteTarget !== undefined) {
|
||||
return def.dir > 0 ? stat >= dd.sereniteTarget : stat <= dd.sereniteTarget;
|
||||
}
|
||||
return def.dir > 0 ? stat >= sd.max : stat <= sd.min;
|
||||
});
|
||||
});
|
||||
if (allTargetsAlreadyMet) return; // Ne pas démarrer si tout est déjà atteint
|
||||
|
||||
enc.timer.running = true;
|
||||
enc.timer.startTime = now;
|
||||
enc.timer.pausedAt = null;
|
||||
enc.timer.pausedMs = 0;
|
||||
enc.timer.snapGauges = { ...enc.gaugeLevels };
|
||||
enc.timer.gaugeRecharges = {};
|
||||
const snapStats: Record<string, Record<string, number>> = {};
|
||||
for (const dd of enc.dragodindes) {
|
||||
snapStats[dd.id] = { ...dd.stats };
|
||||
}
|
||||
enc.timer.snapStats = snapStats;
|
||||
enc.alerted = {};
|
||||
}
|
||||
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
13
src/application/commands/StopTimer.ts
Executable file
13
src/application/commands/StopTimer.ts
Executable file
@ -0,0 +1,13 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface StopTimerCommand { type: 'stop-timer'; enclosId: number; }
|
||||
|
||||
export function createStopTimerHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: StopTimerCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || !enc.timer.running) return;
|
||||
enc.timer.running = false;
|
||||
enc.timer.pausedAt = Date.now();
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
42
src/application/commands/UpdateGauge.ts
Executable file
42
src/application/commands/UpdateGauge.ts
Executable file
@ -0,0 +1,42 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
import { MAX_GAUGES } from '@domain/entities/Enclos';
|
||||
|
||||
export interface ToggleGaugeCommand { type: 'toggle-gauge'; enclosId: number; gaugeId: GaugeType; }
|
||||
export interface UpdateGaugeLevelCommand { type: 'update-gauge-level'; enclosId: number; gaugeId: GaugeType; level: number; }
|
||||
|
||||
export function createToggleGaugeHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: ToggleGaugeCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc || enc.timer.running) return;
|
||||
const i = enc.activeGauges.indexOf(cmd.gaugeId);
|
||||
if (i >= 0) {
|
||||
enc.activeGauges.splice(i, 1);
|
||||
} else {
|
||||
// Exclusion mutuelle baffeur/caresseur : même stat, directions opposées
|
||||
const SEREN_PAIR: Record<string, GaugeType> = { baffeur: 'caresseur', caresseur: 'baffeur' };
|
||||
const opposite = SEREN_PAIR[cmd.gaugeId];
|
||||
if (opposite) {
|
||||
const oi = enc.activeGauges.indexOf(opposite);
|
||||
if (oi >= 0) enc.activeGauges.splice(oi, 1);
|
||||
}
|
||||
if (enc.activeGauges.length >= MAX_GAUGES) enc.activeGauges.shift();
|
||||
enc.activeGauges.push(cmd.gaugeId);
|
||||
}
|
||||
// Reset timer if gauge changed after a completed session
|
||||
if (enc.timer.startTime && !enc.timer.running) {
|
||||
enc.timer = { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} };
|
||||
enc.alerted = {};
|
||||
}
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
|
||||
export function createUpdateGaugeLevelHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateGaugeLevelCommand): void => {
|
||||
const enc = state.enclos.find(e => e.id === cmd.enclosId);
|
||||
if (!enc) return;
|
||||
enc.gaugeLevels[cmd.gaugeId] = Math.max(0, Math.min(100000, cmd.level));
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
19
src/application/commands/UpdateSettings.ts
Executable file
19
src/application/commands/UpdateSettings.ts
Executable file
@ -0,0 +1,19 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface UpdateSettingsCommand {
|
||||
type: 'update-settings';
|
||||
alarmSound?: string;
|
||||
notifsEnabled?: boolean;
|
||||
ntfyTopic?: string;
|
||||
inventaire?: Record<string, { m: number; f: number }>;
|
||||
}
|
||||
|
||||
export function createUpdateSettingsHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateSettingsCommand): void => {
|
||||
if (cmd.alarmSound !== undefined) state.alarmSound = cmd.alarmSound;
|
||||
if (cmd.notifsEnabled !== undefined) state.notifsEnabled = cmd.notifsEnabled;
|
||||
if (cmd.ntfyTopic !== undefined) state.ntfyTopic = cmd.ntfyTopic;
|
||||
if (cmd.inventaire !== undefined) state.inventaire = cmd.inventaire;
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
32
src/application/commands/UpdateWorkflow.ts
Executable file
32
src/application/commands/UpdateWorkflow.ts
Executable file
@ -0,0 +1,32 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import type { WorkflowItem } from '@application/queries/GetWorkflows';
|
||||
|
||||
export interface UpdateWorkflowCommand {
|
||||
type: 'update-workflow';
|
||||
workflowId: number;
|
||||
materialIdx?: number;
|
||||
stepIdx?: number;
|
||||
crossingIdx?: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export function createUpdateWorkflowHandler(state: AppState, repo: StateRepository) {
|
||||
return (cmd: UpdateWorkflowCommand): void => {
|
||||
const workflows = state.workflows as WorkflowItem[];
|
||||
const wf = workflows.find(w => w.id === cmd.workflowId);
|
||||
if (!wf) return;
|
||||
|
||||
if (cmd.materialIdx !== undefined) {
|
||||
const mat = wf.materials[cmd.materialIdx];
|
||||
if (mat) mat.done = Math.max(0, cmd.done);
|
||||
} else if (cmd.stepIdx !== undefined && cmd.crossingIdx !== undefined) {
|
||||
const step = wf.steps[cmd.stepIdx];
|
||||
if (step) {
|
||||
const crossing = step.crossings[cmd.crossingIdx];
|
||||
if (crossing) crossing.done = Math.max(0, cmd.done);
|
||||
}
|
||||
}
|
||||
|
||||
repo.save(state);
|
||||
};
|
||||
}
|
||||
24
src/application/handlers/CommandBus.ts
Executable file
24
src/application/handlers/CommandBus.ts
Executable file
@ -0,0 +1,24 @@
|
||||
export interface Command {
|
||||
readonly type: string;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
type CommandHandler<T extends Command = Command> = (cmd: T) => void;
|
||||
|
||||
export class CommandBus {
|
||||
private handlers = new Map<string, CommandHandler>();
|
||||
|
||||
register<T extends Command>(type: string, handler: CommandHandler<T>): void {
|
||||
this.handlers.set(type, handler as CommandHandler);
|
||||
}
|
||||
|
||||
execute(cmd: Command): void {
|
||||
const handler = this.handlers.get(cmd.type);
|
||||
if (!handler) throw new Error(`No handler for command: ${cmd.type}`);
|
||||
handler(cmd);
|
||||
}
|
||||
|
||||
has(type: string): boolean {
|
||||
return this.handlers.has(type);
|
||||
}
|
||||
}
|
||||
24
src/application/handlers/QueryBus.ts
Executable file
24
src/application/handlers/QueryBus.ts
Executable file
@ -0,0 +1,24 @@
|
||||
export interface Query {
|
||||
readonly type: string;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
type QueryHandler<T extends Query = Query, R = unknown> = (query: T) => R;
|
||||
|
||||
export class QueryBus {
|
||||
private handlers = new Map<string, QueryHandler>();
|
||||
|
||||
register<T extends Query, R>(type: string, handler: QueryHandler<T, R>): void {
|
||||
this.handlers.set(type, handler as QueryHandler);
|
||||
}
|
||||
|
||||
execute<R>(query: Query): R {
|
||||
const handler = this.handlers.get(query.type);
|
||||
if (!handler) throw new Error(`No handler for query: ${query.type}`);
|
||||
return handler(query) as R;
|
||||
}
|
||||
|
||||
has(type: string): boolean {
|
||||
return this.handlers.has(type);
|
||||
}
|
||||
}
|
||||
15
src/application/queries/GetBreedingOptions.ts
Executable file
15
src/application/queries/GetBreedingOptions.ts
Executable file
@ -0,0 +1,15 @@
|
||||
import { BreedingService, type PartnerInfo } from '@domain/services/BreedingService';
|
||||
|
||||
export interface GetBreedingOptionsQuery { type: 'get-breeding-options'; race: string; }
|
||||
|
||||
export interface BreedingOptionsResult {
|
||||
partners: readonly PartnerInfo[];
|
||||
}
|
||||
|
||||
const breedingSvc = new BreedingService();
|
||||
|
||||
export function createGetBreedingOptionsHandler() {
|
||||
return (query: GetBreedingOptionsQuery): BreedingOptionsResult => {
|
||||
return { partners: breedingSvc.getCompatiblePartners(query.race) };
|
||||
};
|
||||
}
|
||||
57
src/application/queries/GetDashboard.ts
Executable file
57
src/application/queries/GetDashboard.ts
Executable file
@ -0,0 +1,57 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
import { elapsed } from '@domain/services/GaugeCalculator';
|
||||
|
||||
export interface DashboardQuery { type: 'get-dashboard'; }
|
||||
|
||||
export interface EnclosSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
ddCount: number;
|
||||
running: boolean;
|
||||
elapsedSec: number;
|
||||
activeGauges: string[];
|
||||
}
|
||||
|
||||
export interface DashboardResult {
|
||||
enclosSummaries: EnclosSummary[];
|
||||
totalCouples: number;
|
||||
totalBabies: number;
|
||||
raceBreakdown: Record<string, number>;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export function createGetDashboardHandler(state: AppState) {
|
||||
return (_query: DashboardQuery): DashboardResult => {
|
||||
const summaries: EnclosSummary[] = state.enclos.map(enc => ({
|
||||
id: enc.id,
|
||||
name: enc.name,
|
||||
ddCount: enc.dragodindes.length,
|
||||
running: enc.timer.running,
|
||||
elapsedSec: elapsed(enc.timer),
|
||||
activeGauges: [...enc.activeGauges],
|
||||
}));
|
||||
|
||||
let totalCouples = 0, totalBabies = 0;
|
||||
const raceBreakdown: Record<string, number> = {};
|
||||
|
||||
// From accouplements
|
||||
for (const acc of state.accouplements) {
|
||||
totalCouples += acc.couples;
|
||||
totalBabies += acc.babiesObtained;
|
||||
raceBreakdown[acc.baby] = (raceBreakdown[acc.baby] ?? 0) + acc.babiesObtained;
|
||||
}
|
||||
|
||||
// From archived stats (legacy migration)
|
||||
for (const arch of state.archivedStats as Array<{ baby?: string; couples?: number; babiesObtained?: number }>) {
|
||||
if (arch.baby) {
|
||||
totalCouples += arch.couples ?? 0;
|
||||
totalBabies += arch.babiesObtained ?? 0;
|
||||
raceBreakdown[arch.baby] = (raceBreakdown[arch.baby] ?? 0) + (arch.babiesObtained ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const successRate = totalCouples > 0 ? Math.round((totalBabies / totalCouples) * 100) : 0;
|
||||
|
||||
return { enclosSummaries: summaries, totalCouples, totalBabies, raceBreakdown, successRate };
|
||||
};
|
||||
}
|
||||
10
src/application/queries/GetEnclosDetail.ts
Executable file
10
src/application/queries/GetEnclosDetail.ts
Executable file
@ -0,0 +1,10 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
|
||||
export interface GetEnclosDetailQuery { type: 'get-enclos-detail'; enclosId: number; }
|
||||
|
||||
export function createGetEnclosDetailHandler(state: AppState) {
|
||||
return (query: GetEnclosDetailQuery): Enclos | null => {
|
||||
return state.enclos.find(e => e.id === query.enclosId) ?? null;
|
||||
};
|
||||
}
|
||||
9
src/application/queries/GetInventaire.ts
Executable file
9
src/application/queries/GetInventaire.ts
Executable file
@ -0,0 +1,9 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface GetInventaireQuery { type: 'get-inventaire'; }
|
||||
|
||||
export function createGetInventaireHandler(state: AppState) {
|
||||
return (_query: GetInventaireQuery): Record<string, { m: number; f: number }> => {
|
||||
return state.inventaire;
|
||||
};
|
||||
}
|
||||
22
src/application/queries/GetReapproTree.ts
Executable file
22
src/application/queries/GetReapproTree.ts
Executable file
@ -0,0 +1,22 @@
|
||||
import { ReapproCalculator, type ReapproResult } from '@domain/services/ReapproCalculator';
|
||||
|
||||
export interface GetReapproTreeQuery {
|
||||
type: 'get-reappro-tree';
|
||||
target: string;
|
||||
qty: number;
|
||||
repro: Record<string, number>;
|
||||
inverted: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const calculator = new ReapproCalculator();
|
||||
|
||||
export function createGetReapproTreeHandler() {
|
||||
return (query: GetReapproTreeQuery): ReapproResult => {
|
||||
return calculator.compute({
|
||||
target: query.target,
|
||||
qty: query.qty,
|
||||
repro: query.repro,
|
||||
inverted: query.inverted,
|
||||
});
|
||||
};
|
||||
}
|
||||
17
src/application/queries/GetSettings.ts
Executable file
17
src/application/queries/GetSettings.ts
Executable file
@ -0,0 +1,17 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface GetSettingsQuery { type: 'get-settings'; }
|
||||
|
||||
export interface SettingsResult {
|
||||
alarmSound: string;
|
||||
notifsEnabled: boolean;
|
||||
ntfyTopic: string;
|
||||
}
|
||||
|
||||
export function createGetSettingsHandler(state: AppState) {
|
||||
return (_query: GetSettingsQuery): SettingsResult => ({
|
||||
alarmSound: state.alarmSound,
|
||||
notifsEnabled: state.notifsEnabled,
|
||||
ntfyTopic: state.ntfyTopic,
|
||||
});
|
||||
}
|
||||
320
src/application/queries/GetStatistics.ts
Executable file
320
src/application/queries/GetStatistics.ts
Executable file
@ -0,0 +1,320 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
import { RACE_GEN } from '@domain/value-objects/Race';
|
||||
|
||||
// 66 races totales moins 3 Gen 1 (Rousse, Dorée, Amande) qui se capturent et ne se créent pas
|
||||
export const TOTAL_RACES = 63;
|
||||
|
||||
export interface GetStatisticsQuery {
|
||||
type: 'get-statistics';
|
||||
days?: number; // 0 = tout l'historique
|
||||
}
|
||||
|
||||
export interface DailyBirths {
|
||||
date: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RaceShare {
|
||||
race: string;
|
||||
count: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
export interface KpiDelta {
|
||||
value: number;
|
||||
delta: number | null;
|
||||
}
|
||||
|
||||
export interface RaceSuccessRate {
|
||||
race: string;
|
||||
couples: number;
|
||||
babies: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface BestCouple {
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
baby: string;
|
||||
couples: number;
|
||||
babies: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface GenBreakdown {
|
||||
gen: number;
|
||||
babies: number;
|
||||
couples: number;
|
||||
races: number;
|
||||
}
|
||||
|
||||
export interface MissingRace {
|
||||
name: string;
|
||||
gen: number;
|
||||
}
|
||||
|
||||
export interface WeekdayActivity {
|
||||
day: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface StatisticsResult {
|
||||
totalBabies: KpiDelta;
|
||||
totalCouples: KpiDelta;
|
||||
successRate: KpiDelta;
|
||||
racesCount: KpiDelta;
|
||||
dailyBirths: DailyBirths[];
|
||||
raceShares: RaceShare[];
|
||||
raceSuccessRates: RaceSuccessRate[];
|
||||
bestCouples: BestCouple[];
|
||||
genBreakdown: GenBreakdown[];
|
||||
missingRaces: MissingRace[];
|
||||
weekdayActivity: WeekdayActivity[];
|
||||
days: number;
|
||||
}
|
||||
|
||||
interface AccEntry {
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
baby: string;
|
||||
gen: number;
|
||||
couples: number;
|
||||
babiesObtained: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
function toISO(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Normalise une date (string ISO, timestamp number, Date object) en string "YYYY-MM-DD". */
|
||||
function normalizeDate(raw: unknown): string {
|
||||
if (!raw) return '';
|
||||
if (typeof raw === 'string') return raw.slice(0, 10);
|
||||
if (typeof raw === 'number') return new Date(raw).toISOString().slice(0, 10);
|
||||
if (raw instanceof Date) return raw.toISOString().slice(0, 10);
|
||||
return String(raw).slice(0, 10);
|
||||
}
|
||||
|
||||
function aggregate(entries: AccEntry[]) {
|
||||
let couples = 0, babies = 0;
|
||||
const races = new Set<string>();
|
||||
for (const e of entries) {
|
||||
couples += e.couples;
|
||||
babies += e.babiesObtained;
|
||||
if (e.babiesObtained > 0) races.add(e.baby);
|
||||
}
|
||||
const rate = couples > 0 ? Math.round((babies / couples) * 100) : 0;
|
||||
return { couples, babies, rate, racesCount: races.size };
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||
|
||||
function emptyResult(days: number): StatisticsResult {
|
||||
const kpi0: KpiDelta = { value: 0, delta: null };
|
||||
const missingRaces: MissingRace[] = Object.entries(RACE_GEN)
|
||||
.filter(([, gen]) => gen !== 1)
|
||||
.map(([name, gen]) => ({ name, gen }))
|
||||
.sort((a, b) => a.gen - b.gen || a.name.localeCompare(b.name));
|
||||
return {
|
||||
totalBabies: kpi0, totalCouples: kpi0, successRate: kpi0, racesCount: kpi0,
|
||||
dailyBirths: [], raceShares: [], raceSuccessRates: [], bestCouples: [],
|
||||
genBreakdown: [], missingRaces, weekdayActivity: [], days,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetStatisticsHandler(state: AppState) {
|
||||
return (query: GetStatisticsQuery): StatisticsResult => {
|
||||
const days = query.days ?? 30;
|
||||
try {
|
||||
return computeStatistics(state, days);
|
||||
} catch (e) {
|
||||
console.error('GetStatistics handler error:', e);
|
||||
return emptyResult(days);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function computeStatistics(state: AppState, days: number): StatisticsResult {
|
||||
// Collecter toutes les entrées normalisées (exclure Gen 1 : non créables, seulement capturables)
|
||||
const all: AccEntry[] = [];
|
||||
const accouplements = Array.isArray(state.accouplements) ? state.accouplements : [];
|
||||
for (const acc of accouplements) {
|
||||
if (!acc || !acc.baby) continue;
|
||||
if (acc.gen === 1 || (RACE_GEN[acc.baby] ?? 0) === 1) continue;
|
||||
all.push({
|
||||
parentA: acc.parentA ?? '', parentB: acc.parentB ?? '',
|
||||
baby: acc.baby, gen: acc.gen ?? (RACE_GEN[acc.baby] ?? 0),
|
||||
couples: Number(acc.couples) || 0, babiesObtained: Number(acc.babiesObtained) || 0,
|
||||
date: normalizeDate(acc.date),
|
||||
});
|
||||
}
|
||||
const archivedStats = Array.isArray(state.archivedStats) ? state.archivedStats : [];
|
||||
for (const arch of archivedStats as Array<{
|
||||
parentA?: string; parentB?: string; baby?: string; gen?: number;
|
||||
couples?: number; babiesObtained?: number; date?: string;
|
||||
}>) {
|
||||
if (!arch || !arch.baby) continue;
|
||||
const gen = arch.gen ?? (RACE_GEN[arch.baby] ?? 0);
|
||||
if (gen === 1) continue;
|
||||
all.push({
|
||||
parentA: arch.parentA ?? '', parentB: arch.parentB ?? '',
|
||||
baby: arch.baby, gen,
|
||||
couples: Number(arch.couples) || 0,
|
||||
babiesObtained: Number(arch.babiesObtained) || 0,
|
||||
date: normalizeDate(arch.date),
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayISO = toISO(now);
|
||||
|
||||
let current: AccEntry[];
|
||||
let previous: AccEntry[] | null;
|
||||
|
||||
if (days === 0) {
|
||||
current = all;
|
||||
previous = null;
|
||||
} else {
|
||||
const startCurrent = new Date(now);
|
||||
startCurrent.setDate(startCurrent.getDate() - days);
|
||||
const startCurrentISO = toISO(startCurrent);
|
||||
|
||||
const startPrevious = new Date(startCurrent);
|
||||
startPrevious.setDate(startPrevious.getDate() - days);
|
||||
const startPreviousISO = toISO(startPrevious);
|
||||
|
||||
current = all.filter(e => {
|
||||
const d = e.date.slice(0, 10);
|
||||
return d >= startCurrentISO && d <= todayISO;
|
||||
});
|
||||
previous = all.filter(e => {
|
||||
const d = e.date.slice(0, 10);
|
||||
return d >= startPreviousISO && d < startCurrentISO;
|
||||
});
|
||||
}
|
||||
|
||||
const cur = aggregate(current);
|
||||
const prev = previous ? aggregate(previous) : null;
|
||||
|
||||
function delta(curVal: number, prevVal: number | null): KpiDelta {
|
||||
if (prevVal === null) return { value: curVal, delta: null };
|
||||
return { value: curVal, delta: curVal - prevVal };
|
||||
}
|
||||
|
||||
// ── Naissances par jour ──────────────────────────────────────
|
||||
const dailyMap: Record<string, number> = {};
|
||||
for (const e of current) {
|
||||
const day = e.date.slice(0, 10);
|
||||
if (day) dailyMap[day] = (dailyMap[day] ?? 0) + e.babiesObtained;
|
||||
}
|
||||
const chartDays = days === 0 ? 30 : days;
|
||||
const dailyBirths: DailyBirths[] = [];
|
||||
for (let i = chartDays - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
const iso = toISO(d);
|
||||
const label = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
dailyBirths.push({ date: iso, label, count: dailyMap[iso] ?? 0 });
|
||||
}
|
||||
|
||||
// ── Répartition des races ────────────────────────────────────
|
||||
const raceBreakdown: Record<string, number> = {};
|
||||
let totalBabiesForShares = 0;
|
||||
for (const e of current) {
|
||||
raceBreakdown[e.baby] = (raceBreakdown[e.baby] ?? 0) + e.babiesObtained;
|
||||
totalBabiesForShares += e.babiesObtained;
|
||||
}
|
||||
const raceShares: RaceShare[] = Object.entries(raceBreakdown)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([race, count]) => ({
|
||||
race, count,
|
||||
pct: totalBabiesForShares > 0 ? Math.round((count / totalBabiesForShares) * 100) : 0,
|
||||
}));
|
||||
|
||||
// ── Taux de réussite par race ────────────────────────────────
|
||||
const raceAgg: Record<string, { couples: number; babies: number }> = {};
|
||||
for (const e of current) {
|
||||
if (!raceAgg[e.baby]) raceAgg[e.baby] = { couples: 0, babies: 0 };
|
||||
raceAgg[e.baby].couples += e.couples;
|
||||
raceAgg[e.baby].babies += e.babiesObtained;
|
||||
}
|
||||
const raceSuccessRates: RaceSuccessRate[] = Object.entries(raceAgg)
|
||||
.map(([race, { couples, babies }]) => ({
|
||||
race, couples, babies,
|
||||
rate: couples > 0 ? Math.round((babies / couples) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
// ── Meilleurs couples ────────────────────────────────────────
|
||||
const coupleAgg: Record<string, { parentA: string; parentB: string; baby: string; couples: number; babies: number }> = {};
|
||||
for (const e of current) {
|
||||
if (!e.parentA || !e.parentB) continue;
|
||||
// Clé normalisée (ordre alphabétique) pour éviter les doublons A+B / B+A
|
||||
const key = [e.parentA, e.parentB].sort().join('|');
|
||||
if (!coupleAgg[key]) {
|
||||
coupleAgg[key] = { parentA: e.parentA, parentB: e.parentB, baby: e.baby, couples: 0, babies: 0 };
|
||||
}
|
||||
coupleAgg[key].couples += e.couples;
|
||||
coupleAgg[key].babies += e.babiesObtained;
|
||||
}
|
||||
const bestCouples: BestCouple[] = Object.values(coupleAgg)
|
||||
.map(c => ({ ...c, rate: c.couples > 0 ? Math.round((c.babies / c.couples) * 100) : 0 }))
|
||||
.sort((a, b) => b.rate - a.rate || b.babies - a.babies)
|
||||
.slice(0, 10);
|
||||
|
||||
// ── Répartition par génération ───────────────────────────────
|
||||
const genAgg: Record<number, { babies: number; couples: number; races: Set<string> }> = {};
|
||||
for (const e of current) {
|
||||
const g = e.gen || (RACE_GEN[e.baby] ?? 0);
|
||||
if (!g) continue;
|
||||
if (!genAgg[g]) genAgg[g] = { babies: 0, couples: 0, races: new Set() };
|
||||
genAgg[g].babies += e.babiesObtained;
|
||||
genAgg[g].couples += e.couples;
|
||||
if (e.babiesObtained > 0) genAgg[g].races.add(e.baby);
|
||||
}
|
||||
const genBreakdown: GenBreakdown[] = Object.entries(genAgg)
|
||||
.map(([g, v]) => ({ gen: Number(g), babies: v.babies, couples: v.couples, races: v.races.size }))
|
||||
.sort((a, b) => a.gen - b.gen);
|
||||
|
||||
// ── Races manquantes ─────────────────────────────────────────
|
||||
const obtainedAll = new Set<string>();
|
||||
for (const e of all) {
|
||||
if (e.babiesObtained > 0) obtainedAll.add(e.baby);
|
||||
}
|
||||
const missingRaces: MissingRace[] = Object.entries(RACE_GEN)
|
||||
.filter(([name, gen]) => gen !== 1 && !obtainedAll.has(name))
|
||||
.map(([name, gen]) => ({ name, gen }))
|
||||
.sort((a, b) => a.gen - b.gen || a.name.localeCompare(b.name));
|
||||
|
||||
// ── Activité par jour de la semaine ──────────────────────────
|
||||
const weekdayCounts = [0, 0, 0, 0, 0, 0, 0];
|
||||
for (const e of current) {
|
||||
const d = e.date.slice(0, 10);
|
||||
if (!d) continue;
|
||||
const dt = new Date(d + 'T12:00:00');
|
||||
weekdayCounts[dt.getDay()] += e.babiesObtained;
|
||||
}
|
||||
// Réordonner : Lundi → Dimanche
|
||||
const weekdayActivity: WeekdayActivity[] = [];
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
const idx = i % 7;
|
||||
weekdayActivity.push({ day: WEEKDAY_NAMES[idx], count: weekdayCounts[idx] });
|
||||
}
|
||||
|
||||
return {
|
||||
totalBabies: delta(cur.babies, prev?.babies ?? null),
|
||||
totalCouples: delta(cur.couples, prev?.couples ?? null),
|
||||
successRate: delta(cur.rate, prev?.rate ?? null),
|
||||
racesCount: delta(cur.racesCount, prev?.racesCount ?? null),
|
||||
dailyBirths,
|
||||
raceShares,
|
||||
raceSuccessRates,
|
||||
bestCouples,
|
||||
genBreakdown,
|
||||
missingRaces,
|
||||
weekdayActivity,
|
||||
days,
|
||||
};
|
||||
}
|
||||
22
src/application/queries/GetTimerState.ts
Executable file
22
src/application/queries/GetTimerState.ts
Executable file
@ -0,0 +1,22 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
import { elapsed } from '@domain/services/GaugeCalculator';
|
||||
|
||||
export interface GetTimerStateQuery { type: 'get-timer-state'; enclosId: number; }
|
||||
|
||||
export interface TimerStateResult {
|
||||
running: boolean;
|
||||
elapsedSec: number;
|
||||
startTime: number | null;
|
||||
}
|
||||
|
||||
export function createGetTimerStateHandler(state: AppState) {
|
||||
return (query: GetTimerStateQuery): TimerStateResult | null => {
|
||||
const enc = state.enclos.find(e => e.id === query.enclosId);
|
||||
if (!enc) return null;
|
||||
return {
|
||||
running: enc.timer.running,
|
||||
elapsedSec: elapsed(enc.timer),
|
||||
startTime: enc.timer.startTime,
|
||||
};
|
||||
};
|
||||
}
|
||||
20
src/application/queries/GetWorkflows.ts
Executable file
20
src/application/queries/GetWorkflows.ts
Executable file
@ -0,0 +1,20 @@
|
||||
import type { AppState } from '@domain/ports/StateRepository';
|
||||
|
||||
export interface GetWorkflowsQuery { type: 'get-workflows'; }
|
||||
|
||||
export interface WorkflowItem {
|
||||
id: number;
|
||||
name: string;
|
||||
target: string;
|
||||
qty: number;
|
||||
createdAt: number;
|
||||
materials: Array<{ name: string; needed: number; done: number }>;
|
||||
steps: Array<{
|
||||
gen: number;
|
||||
crossings: Array<{ race: string; needed: number; parentA: string; parentB: string; couples: number; repro: number; done: number }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function createGetWorkflowsHandler(state: AppState) {
|
||||
return (_query: GetWorkflowsQuery): WorkflowItem[] => state.workflows as WorkflowItem[];
|
||||
}
|
||||
16
src/domain/entities/Accouplement.ts
Executable file
16
src/domain/entities/Accouplement.ts
Executable file
@ -0,0 +1,16 @@
|
||||
export interface Accouplement {
|
||||
readonly parentA: string;
|
||||
readonly parentB: string;
|
||||
readonly baby: string;
|
||||
readonly gen: number;
|
||||
readonly couples: number;
|
||||
readonly babiesObtained: number;
|
||||
readonly date: string;
|
||||
}
|
||||
|
||||
export function createAccouplement(
|
||||
parentA: string, parentB: string, baby: string, gen: number,
|
||||
couples: number, babiesObtained: number
|
||||
): Accouplement {
|
||||
return { parentA, parentB, baby, gen, couples, babiesObtained, date: new Date().toISOString() };
|
||||
}
|
||||
37
src/domain/entities/Dragodinde.ts
Executable file
37
src/domain/entities/Dragodinde.ts
Executable file
@ -0,0 +1,37 @@
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType';
|
||||
import type { Gender } from '@domain/value-objects/Gender';
|
||||
|
||||
export interface DragodindeStats {
|
||||
serenite: number;
|
||||
endurance: number;
|
||||
maturite: number;
|
||||
amour: number;
|
||||
xp: number;
|
||||
}
|
||||
|
||||
export interface Dragodinde {
|
||||
readonly id: number;
|
||||
name: string;
|
||||
race: string;
|
||||
gender: Gender;
|
||||
stats: DragodindeStats;
|
||||
targets: Record<GaugeType, number>;
|
||||
sereniteTarget: number | null;
|
||||
levelTarget: number | null;
|
||||
reproducteur: number;
|
||||
}
|
||||
|
||||
export function createDragodinde(id: number): Dragodinde {
|
||||
return {
|
||||
id,
|
||||
name: `Dragodinde ${id}`,
|
||||
race: '',
|
||||
gender: 'n',
|
||||
stats: { serenite: 0, endurance: 0, maturite: 0, amour: 0, xp: 1 },
|
||||
targets: { ...DEFAULT_TARGETS },
|
||||
sereniteTarget: null,
|
||||
levelTarget: null,
|
||||
reproducteur: 0,
|
||||
};
|
||||
}
|
||||
59
src/domain/entities/Enclos.ts
Executable file
59
src/domain/entities/Enclos.ts
Executable file
@ -0,0 +1,59 @@
|
||||
import type { Dragodinde } from './Dragodinde';
|
||||
import { createDragodinde } from './Dragodinde';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
|
||||
export const MAX_DD = 10;
|
||||
export const MAX_ENCLOS = 6;
|
||||
export const MAX_GAUGES = 2;
|
||||
|
||||
export interface GaugeRecharge {
|
||||
atSec: number; // secondes écoulées au moment de la recharge
|
||||
level: number; // nouveau niveau de jauge
|
||||
}
|
||||
|
||||
export interface TimerData {
|
||||
running: boolean;
|
||||
startTime: number | null;
|
||||
pausedAt: number | null;
|
||||
pausedMs: number;
|
||||
snapGauges: Record<string, number>;
|
||||
snapStats: Record<string, Record<string, number>>;
|
||||
gaugeRecharges: Record<string, GaugeRecharge[]>;
|
||||
}
|
||||
|
||||
export interface Enclos {
|
||||
readonly id: number;
|
||||
name: string;
|
||||
activeGauges: GaugeType[];
|
||||
gaugeLevels: Record<GaugeType, number>;
|
||||
dragodindes: Dragodinde[];
|
||||
nextDdId: number;
|
||||
timer: TimerData;
|
||||
alerted: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function createEnclos(id: number, name?: string): Enclos {
|
||||
return {
|
||||
id,
|
||||
name: name ?? `Enclos ${id}`,
|
||||
activeGauges: [],
|
||||
gaugeLevels: { baffeur: 0, caresseur: 0, foudroyeur: 0, abreuvoir: 0, dragofesse: 0, mangeoire: 0 },
|
||||
dragodindes: [],
|
||||
nextDdId: 1,
|
||||
timer: { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {}, gaugeRecharges: {} },
|
||||
alerted: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function addDragodinde(enc: Enclos): Enclos {
|
||||
if (enc.dragodindes.length >= MAX_DD) return enc;
|
||||
const usedIds = new Set(enc.dragodindes.map(d => d.id));
|
||||
let newId = 1;
|
||||
while (usedIds.has(newId)) newId++;
|
||||
const dd = createDragodinde(newId);
|
||||
return { ...enc, dragodindes: [...enc.dragodindes, dd], nextDdId: newId + 1 };
|
||||
}
|
||||
|
||||
export function removeDragodinde(enc: Enclos, ddId: number): Enclos {
|
||||
return { ...enc, dragodindes: enc.dragodindes.filter(d => d.id !== ddId) };
|
||||
}
|
||||
6
src/domain/events/DomainEvent.ts
Executable file
6
src/domain/events/DomainEvent.ts
Executable file
@ -0,0 +1,6 @@
|
||||
export type DomainEventType = 'timer-completed' | 'gauge-threshold-reached' | 'accouplement-registered' | 'enclos-deleted';
|
||||
|
||||
export interface DomainEvent {
|
||||
readonly type: DomainEventType;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
24
src/domain/events/EventBus.ts
Executable file
24
src/domain/events/EventBus.ts
Executable file
@ -0,0 +1,24 @@
|
||||
import type { DomainEvent, DomainEventType } from './DomainEvent';
|
||||
|
||||
type Handler = (event: DomainEvent) => void;
|
||||
|
||||
export class EventBus {
|
||||
private handlers = new Map<DomainEventType, Handler[]>();
|
||||
|
||||
on(type: DomainEventType, handler: Handler): void {
|
||||
if (!this.handlers.has(type)) this.handlers.set(type, []);
|
||||
this.handlers.get(type)!.push(handler);
|
||||
}
|
||||
|
||||
off(type: DomainEventType, handler: Handler): void {
|
||||
const list = this.handlers.get(type);
|
||||
if (!list) return;
|
||||
const idx = list.indexOf(handler);
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
}
|
||||
|
||||
emit(event: DomainEvent): void {
|
||||
const handlers = this.handlers.get(event.type) ?? [];
|
||||
for (const h of handlers) h(event);
|
||||
}
|
||||
}
|
||||
4
src/domain/ports/AlarmPort.ts
Executable file
4
src/domain/ports/AlarmPort.ts
Executable file
@ -0,0 +1,4 @@
|
||||
export interface AlarmPort {
|
||||
play(soundName: string): void;
|
||||
stop(): void;
|
||||
}
|
||||
4
src/domain/ports/NotificationPort.ts
Executable file
4
src/domain/ports/NotificationPort.ts
Executable file
@ -0,0 +1,4 @@
|
||||
export interface NotificationPort {
|
||||
showNotification(title: string, body: string): void;
|
||||
sendMobileNotification(url: string, title: string, message: string): void;
|
||||
}
|
||||
20
src/domain/ports/StateRepository.ts
Executable file
20
src/domain/ports/StateRepository.ts
Executable file
@ -0,0 +1,20 @@
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
import type { Accouplement } from '@domain/entities/Accouplement';
|
||||
|
||||
export interface AppState {
|
||||
enclos: Enclos[];
|
||||
activeId: number | string | null;
|
||||
nextEnclosId: number;
|
||||
alarmSound: string;
|
||||
notifsEnabled: boolean;
|
||||
ntfyTopic: string;
|
||||
archivedStats: unknown[];
|
||||
inventaire: Record<string, { m: number; f: number }>;
|
||||
workflows: unknown[];
|
||||
accouplements: Accouplement[];
|
||||
}
|
||||
|
||||
export interface StateRepository {
|
||||
load(): Promise<AppState | null>;
|
||||
save(state: AppState): void;
|
||||
}
|
||||
11
src/domain/ports/UpdatePort.ts
Executable file
11
src/domain/ports/UpdatePort.ts
Executable file
@ -0,0 +1,11 @@
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
assetName: string;
|
||||
releaseNotes: string;
|
||||
}
|
||||
|
||||
export interface UpdatePort {
|
||||
checkForUpdates(): Promise<UpdateInfo | null>;
|
||||
downloadAndInstall(info: UpdateInfo): void;
|
||||
}
|
||||
28
src/domain/services/BreedingService.ts
Executable file
28
src/domain/services/BreedingService.ts
Executable file
@ -0,0 +1,28 @@
|
||||
import { BREEDING_RECIPES, BREEDING_BY_PARENTS, COMPATIBLE_PARTNERS, RACE_GEN } from '@domain/value-objects/Race';
|
||||
|
||||
export interface PartnerInfo {
|
||||
readonly partner: string;
|
||||
readonly baby: string;
|
||||
readonly gen: number;
|
||||
}
|
||||
|
||||
export class BreedingService {
|
||||
deduceBaby(parent1: string, parent2: string): string | null {
|
||||
return BREEDING_BY_PARENTS[`${parent1}|${parent2}`]
|
||||
?? BREEDING_BY_PARENTS[`${parent2}|${parent1}`]
|
||||
?? null;
|
||||
}
|
||||
|
||||
getCompatiblePartners(race: string): readonly PartnerInfo[] {
|
||||
return (COMPATIBLE_PARTNERS[race] as PartnerInfo[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
getParents(babyRace: string): readonly [string, string] | null {
|
||||
const recipe = BREEDING_RECIPES[babyRace];
|
||||
return recipe ? [recipe[0], recipe[1]] : null;
|
||||
}
|
||||
|
||||
getGeneration(race: string): number {
|
||||
return RACE_GEN[race] ?? 0;
|
||||
}
|
||||
}
|
||||
151
src/domain/services/GaugeCalculator.ts
Executable file
151
src/domain/services/GaugeCalculator.ts
Executable file
@ -0,0 +1,151 @@
|
||||
export interface TimerState {
|
||||
readonly startTime: number | null;
|
||||
readonly running: boolean;
|
||||
readonly pausedAt: number | null;
|
||||
readonly pausedMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Points gained when a gauge of given level runs for `sec` seconds.
|
||||
* Gauge level decreases through tiers: 90k→40, 70k→30, 40k→20, 0→10 pts per 10sec tick.
|
||||
*/
|
||||
export function gainedIn(lvl: number, sec: number): number {
|
||||
const T = [
|
||||
{ lo: 90000, r: 40 },
|
||||
{ lo: 70000, r: 30 },
|
||||
{ lo: 40000, r: 20 },
|
||||
{ lo: 0, r: 10 },
|
||||
];
|
||||
let g = Math.min(Math.max(lvl, 0), 100000);
|
||||
let tl = Math.floor(sec / 10);
|
||||
let out = 0;
|
||||
for (const { lo, r } of T) {
|
||||
if (g <= lo || tl <= 0) continue;
|
||||
const a = g - lo;
|
||||
const m = Math.floor(a / r);
|
||||
const u = Math.min(m, tl);
|
||||
out += u * r;
|
||||
tl -= u;
|
||||
g = lo;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds needed to gain `pts` points starting from gauge level `lvl`.
|
||||
* Returns Infinity if impossible (level too low).
|
||||
*/
|
||||
export function timeToGain(lvl: number, pts: number): number {
|
||||
if (pts <= 0) return 0;
|
||||
const T = [
|
||||
{ lo: 90000, r: 40 },
|
||||
{ lo: 70000, r: 30 },
|
||||
{ lo: 40000, r: 20 },
|
||||
{ lo: 0, r: 10 },
|
||||
];
|
||||
let s = 0, rem = pts;
|
||||
let g = Math.min(Math.max(lvl, 0), 100000);
|
||||
for (const { lo, r } of T) {
|
||||
if (g <= lo || rem <= 0) continue;
|
||||
const a = g - lo;
|
||||
const d = Math.min(rem, a);
|
||||
s += Math.ceil(d / r) * 10;
|
||||
rem -= d;
|
||||
g = lo;
|
||||
}
|
||||
return rem > 0 ? Infinity : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gauge level after `sec` seconds of depletion.
|
||||
*/
|
||||
export function gaugeAfter(lvl: number, sec: number): number {
|
||||
const T = [
|
||||
{ lo: 90000, r: 40 },
|
||||
{ lo: 70000, r: 30 },
|
||||
{ lo: 40000, r: 20 },
|
||||
{ lo: 0, r: 10 },
|
||||
];
|
||||
let g = Math.min(Math.max(lvl, 0), 100000);
|
||||
let tl = Math.floor(sec / 10);
|
||||
for (const { lo, r } of T) {
|
||||
if (g <= lo || tl <= 0) continue;
|
||||
const a = g - lo;
|
||||
const m = Math.floor(a / r);
|
||||
const u = Math.min(m, tl);
|
||||
g -= u * r;
|
||||
tl -= u;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
export interface GaugeRecharge {
|
||||
readonly atSec: number;
|
||||
readonly level: number;
|
||||
}
|
||||
|
||||
export interface GaugeState {
|
||||
gained: number; // points accumulés depuis le snapshot
|
||||
curGl: number; // niveau de jauge actuel (après dépletion et recharges)
|
||||
effectiveEl: number; // elapsed effectif (gelé au cap si atteint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les points accumulés et le niveau de jauge actuel en tenant compte
|
||||
* des recharges intermédiaires et du cap absolu (ptsAllowed).
|
||||
*
|
||||
* Algorithme segment par segment :
|
||||
* Pour chaque segment [prevEl → recharge.atSec] :
|
||||
* - gainedIn(prevGl, segDur) points gagnés
|
||||
* - Si le cap est atteint dans ce segment → freeze ici
|
||||
* - Sinon, continuer avec le nouveau niveau après recharge
|
||||
*/
|
||||
export function computeGaugeState(
|
||||
startGl: number,
|
||||
recharges: readonly GaugeRecharge[],
|
||||
ptsAllowed: number,
|
||||
el: number,
|
||||
): GaugeState {
|
||||
let gained = 0;
|
||||
let prevEl = 0;
|
||||
let prevGl = startGl;
|
||||
|
||||
const sorted = recharges.filter(r => r.atSec < el).sort((a, b) => a.atSec - b.atSec);
|
||||
|
||||
for (const r of sorted) {
|
||||
const segDur = r.atSec - prevEl;
|
||||
const segGained = gainedIn(prevGl, segDur);
|
||||
|
||||
if (isFinite(ptsAllowed) && gained + segGained >= ptsAllowed) {
|
||||
const ptsNeeded = ptsAllowed - gained;
|
||||
const secInSeg = timeToGain(prevGl, ptsNeeded);
|
||||
return { gained: ptsAllowed, curGl: Math.max(0, gaugeAfter(prevGl, secInSeg)), effectiveEl: prevEl + secInSeg };
|
||||
}
|
||||
|
||||
gained += segGained;
|
||||
prevEl = r.atSec;
|
||||
prevGl = r.level;
|
||||
}
|
||||
|
||||
// Dernier segment (de la dernière recharge jusqu'à el)
|
||||
const lastDur = el - prevEl;
|
||||
const lastGained = gainedIn(prevGl, lastDur);
|
||||
|
||||
if (isFinite(ptsAllowed) && gained + lastGained >= ptsAllowed) {
|
||||
const ptsNeeded = ptsAllowed - gained;
|
||||
const secInSeg = timeToGain(prevGl, ptsNeeded);
|
||||
return { gained: ptsAllowed, curGl: Math.max(0, gaugeAfter(prevGl, secInSeg)), effectiveEl: prevEl + secInSeg };
|
||||
}
|
||||
|
||||
return { gained: gained + lastGained, curGl: Math.max(0, gaugeAfter(prevGl, lastDur)), effectiveEl: el };
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed seconds for a timer state.
|
||||
*/
|
||||
export function elapsed(timer: TimerState): number {
|
||||
if (!timer.startTime) return 0;
|
||||
if (timer.running) return (Date.now() - timer.startTime - timer.pausedMs) / 1000;
|
||||
if (timer.pausedAt) return (timer.pausedAt - timer.startTime - timer.pausedMs) / 1000;
|
||||
return 0;
|
||||
}
|
||||
69
src/domain/services/InventaireCalculator.ts
Executable file
69
src/domain/services/InventaireCalculator.ts
Executable file
@ -0,0 +1,69 @@
|
||||
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
|
||||
|
||||
export interface Stock { m: number; f: number; n: number; }
|
||||
export interface CrossingDetail { aSex: string; bSex: string; }
|
||||
export interface CrossingResult { name: string; qty: number; parents: readonly [string, string]; gen: number; details: CrossingDetail[]; }
|
||||
export interface GenerationResult { gen: number; crossings: CrossingResult[]; }
|
||||
export interface InventaireResult { generations: GenerationResult[]; remaining: Record<string, Stock>; }
|
||||
|
||||
export class InventaireCalculator {
|
||||
compute(inventaire: Readonly<Record<string, { m: number; f: number }>>): InventaireResult {
|
||||
const avail: Record<string, Stock> = {};
|
||||
for (const [k, v] of Object.entries(inventaire)) {
|
||||
if ((v.m + v.f) > 0) avail[k] = { m: v.m, f: v.f, n: 0 };
|
||||
}
|
||||
|
||||
if (Object.keys(avail).length === 0) return { generations: [], remaining: {} };
|
||||
|
||||
const hasMale = (s: Stock) => s.m > 0 || s.n > 0;
|
||||
const hasFemale = (s: Stock) => s.f > 0 || s.n > 0;
|
||||
const takeMale = (s: Stock) => { if (s.m > 0) s.m--; else s.n--; };
|
||||
const takeFemale = (s: Stock) => { if (s.f > 0) s.f--; else s.n--; };
|
||||
const totalOf = (s: Stock) => s.m + s.f + s.n;
|
||||
|
||||
const generations: GenerationResult[] = [];
|
||||
|
||||
for (let gen = 2; gen <= 10; gen++) {
|
||||
const crossingsAtGen = Object.entries(BREEDING_RECIPES)
|
||||
.filter(([name]) => (RACE_GEN[name] ?? 0) === gen)
|
||||
.map(([name, parents]) => ({ name, parents }));
|
||||
|
||||
const genResults: CrossingResult[] = [];
|
||||
let more = true;
|
||||
while (more) {
|
||||
more = false;
|
||||
for (const cr of crossingsAtGen) {
|
||||
const [a, b] = cr.parents;
|
||||
const sa = avail[a] ?? { m: 0, f: 0, n: 0 };
|
||||
const sb = avail[b] ?? { m: 0, f: 0, n: 0 };
|
||||
let ok = false, aSex = '', bSex = '';
|
||||
|
||||
if (a === b) {
|
||||
if (totalOf(sa) >= 2 && hasMale(sa) && hasFemale(sa)) {
|
||||
takeMale(sa); takeFemale(sa); aSex = '\u2642'; bSex = '\u2640'; ok = true;
|
||||
}
|
||||
} else if (hasMale(sa) && hasFemale(sb)) {
|
||||
takeMale(sa); takeFemale(sb); aSex = '\u2642'; bSex = '\u2640'; ok = true;
|
||||
} else if (hasFemale(sa) && hasMale(sb)) {
|
||||
takeFemale(sa); takeMale(sb); aSex = '\u2640'; bSex = '\u2642'; ok = true;
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (!avail[a]) avail[a] = sa;
|
||||
if (!avail[b]) avail[b] = sb;
|
||||
if (!avail[cr.name]) avail[cr.name] = { m: 0, f: 0, n: 0 };
|
||||
avail[cr.name]!.n++;
|
||||
let entry = genResults.find(r => r.name === cr.name);
|
||||
if (!entry) { entry = { name: cr.name, qty: 0, parents: cr.parents as [string, string], gen, details: [] }; genResults.push(entry); }
|
||||
entry.qty++;
|
||||
entry.details.push({ aSex, bSex });
|
||||
more = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (genResults.length > 0) generations.push({ gen, crossings: genResults });
|
||||
}
|
||||
|
||||
return { generations, remaining: avail };
|
||||
}
|
||||
}
|
||||
80
src/domain/services/ReapproCalculator.ts
Executable file
80
src/domain/services/ReapproCalculator.ts
Executable file
@ -0,0 +1,80 @@
|
||||
import { BREEDING_RECIPES, RACE_GEN } from '@domain/value-objects/Race';
|
||||
|
||||
export interface ReapproInput {
|
||||
target: string;
|
||||
qty: number;
|
||||
repro: Readonly<Record<string, number>>;
|
||||
inverted: Readonly<Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export interface ReapproStep {
|
||||
race: string;
|
||||
gen: number;
|
||||
qty: number;
|
||||
couples: number;
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
repro: number;
|
||||
}
|
||||
|
||||
export interface Gen1Need {
|
||||
name: string;
|
||||
total: number;
|
||||
m: number;
|
||||
f: number;
|
||||
}
|
||||
|
||||
export interface ReapproResult {
|
||||
steps: readonly ReapproStep[];
|
||||
gen1Needs: readonly Gen1Need[];
|
||||
totalGen1: number;
|
||||
}
|
||||
|
||||
export class ReapproCalculator {
|
||||
compute(input: ReapproInput): ReapproResult {
|
||||
const { target, qty, repro, inverted } = input;
|
||||
if (!target || !BREEDING_RECIPES[target]) return { steps: [], gen1Needs: [], totalGen1: 0 };
|
||||
|
||||
const needs: Record<string, { total: number; m: number; f: number }> = {};
|
||||
needs[target] = { total: qty, m: 0, f: 0 };
|
||||
const steps: ReapproStep[] = [];
|
||||
const processed = new Set<string>();
|
||||
const targetGen = RACE_GEN[target] ?? 2;
|
||||
|
||||
for (let gen = targetGen; gen >= 2; gen--) {
|
||||
const racesAtGen = Object.keys(needs).filter(
|
||||
r => !processed.has(r) && (RACE_GEN[r] ?? 0) === gen && !!BREEDING_RECIPES[r]
|
||||
);
|
||||
for (const race of racesAtGen) {
|
||||
const Q = needs[race]!.total;
|
||||
if (Q <= 0) { processed.add(race); continue; }
|
||||
const R = repro[race] ?? 0;
|
||||
const couplesReal = (2 * R >= Q) ? Math.ceil(Q / 2) : (R > 0 ? Q - R : Q);
|
||||
|
||||
const isInv = inverted[race] ?? false;
|
||||
const [rawA, rawB] = BREEDING_RECIPES[race]!;
|
||||
const a = isInv ? rawB : rawA;
|
||||
const b = isInv ? rawA : rawB;
|
||||
|
||||
if (!needs[a]) needs[a] = { total: 0, m: 0, f: 0 };
|
||||
if (!needs[b]) needs[b] = { total: 0, m: 0, f: 0 };
|
||||
needs[a]!.total += couplesReal;
|
||||
needs[a]!.m += couplesReal;
|
||||
needs[b]!.total += couplesReal;
|
||||
needs[b]!.f += couplesReal;
|
||||
|
||||
steps.push({ race, gen, qty: Q, couples: couplesReal, parentA: a, parentB: b, repro: R });
|
||||
processed.add(race);
|
||||
}
|
||||
}
|
||||
|
||||
const gen1Needs: Gen1Need[] = Object.entries(needs)
|
||||
.filter(([n]) => !BREEDING_RECIPES[n] && needs[n]!.total > 0)
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.map(([name, d]) => ({ name, total: d.total, m: d.m, f: d.f }));
|
||||
|
||||
const totalGen1 = gen1Needs.reduce((s, n) => s + n.total, 0);
|
||||
|
||||
return { steps, gen1Needs, totalGen1 };
|
||||
}
|
||||
}
|
||||
34
src/domain/services/SerenityCalculator.ts
Executable file
34
src/domain/services/SerenityCalculator.ts
Executable file
@ -0,0 +1,34 @@
|
||||
import { timeToGain } from './GaugeCalculator';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
|
||||
export interface SerenityEtaInput {
|
||||
currentSerenite: number;
|
||||
target: number | null;
|
||||
activeGauges: readonly string[];
|
||||
gaugeLevels: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface EtaResult {
|
||||
done: boolean;
|
||||
seconds: number;
|
||||
needsGauge?: GaugeType;
|
||||
}
|
||||
|
||||
export class SerenityCalculator {
|
||||
computeEta(input: SerenityEtaInput): EtaResult {
|
||||
const { currentSerenite, target, activeGauges, gaugeLevels } = input;
|
||||
if (target === null || target === undefined) return { done: false, seconds: 0 };
|
||||
const diff = target - currentSerenite;
|
||||
if (diff === 0) return { done: true, seconds: 0 };
|
||||
|
||||
const needUp = diff > 0;
|
||||
const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';
|
||||
|
||||
if (!activeGauges.includes(gid)) return { done: false, seconds: Infinity, needsGauge: gid };
|
||||
|
||||
const gl = gaugeLevels[gid] ?? 0;
|
||||
const pts = Math.abs(diff);
|
||||
const sec = timeToGain(gl, pts);
|
||||
return { done: false, seconds: sec };
|
||||
}
|
||||
}
|
||||
108
src/domain/services/StockSimulator.ts
Executable file
108
src/domain/services/StockSimulator.ts
Executable file
@ -0,0 +1,108 @@
|
||||
import { RACES_DATA, BREEDING_RECIPES } from '@domain/value-objects/Race';
|
||||
|
||||
export interface SimulationCrossing {
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
baby: string;
|
||||
gen: number;
|
||||
count: number;
|
||||
pAMale: number;
|
||||
pAFemale: number;
|
||||
pBMale: number;
|
||||
pBFemale: number;
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
crossings: SimulationCrossing[];
|
||||
unusedStock: { race: string; m: number; f: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simule tous les croisements possibles depuis un inventaire en stock.
|
||||
*
|
||||
* Algorithme d'allocation proportionnelle dynamique :
|
||||
* Pour chaque croisement à une génération donnée, le stock de chaque parent
|
||||
* est divisé équitablement par le nombre de croisements restants qui utilisent
|
||||
* encore ce parent. Cela évite qu'un premier croisement épuise tout le stock
|
||||
* et prive les suivants (ex. : 3 races Gen2 partagent les mêmes parents de base).
|
||||
*
|
||||
* Les deux configurations sont utilisées simultanément :
|
||||
* c1 = min(♂A_alloué, ♀B_alloué) → ♂A × ♀B
|
||||
* c2 = min(♀A_allouée, ♂B_alloué) → ♀A × ♂B
|
||||
* bred = c1 + c2
|
||||
*/
|
||||
export function simulateStock(
|
||||
inventaire: Readonly<Record<string, { m: number; f: number }>>,
|
||||
): SimulationResult {
|
||||
const stock: Record<string, { m: number; f: number }> = {};
|
||||
for (const [race, entry] of Object.entries(inventaire)) {
|
||||
if (entry.m > 0 || entry.f > 0) stock[race] = { m: entry.m, f: entry.f };
|
||||
}
|
||||
|
||||
const crossings: SimulationCrossing[] = [];
|
||||
|
||||
for (let g = 2; g <= 10; g++) {
|
||||
const racesAtGen = RACES_DATA[g];
|
||||
if (!racesAtGen) continue;
|
||||
|
||||
// Crossings possibles à cette génération (les deux parents ont du stock)
|
||||
const genCrossings: { baby: string; parentA: string; parentB: string }[] = [];
|
||||
for (const raceData of racesAtGen) {
|
||||
const recipe = BREEDING_RECIPES[raceData.name];
|
||||
if (!recipe) continue;
|
||||
const [parentA, parentB] = recipe;
|
||||
const sA = stock[parentA];
|
||||
const sB = stock[parentB];
|
||||
if (!sA || !sB || sA.m + sA.f <= 0 || sB.m + sB.f <= 0) continue;
|
||||
genCrossings.push({ baby: raceData.name, parentA, parentB });
|
||||
}
|
||||
|
||||
for (let i = 0; i < genCrossings.length; i++) {
|
||||
const { baby, parentA, parentB } = genCrossings[i]!;
|
||||
const sA = stock[parentA];
|
||||
const sB = stock[parentB];
|
||||
if (!sA || !sB || sA.m + sA.f <= 0 || sB.m + sB.f <= 0) continue;
|
||||
|
||||
// Croisements restants dont les deux parents ont encore du stock
|
||||
const remaining = genCrossings.slice(i).filter(c => {
|
||||
const a = stock[c.parentA];
|
||||
const b = stock[c.parentB];
|
||||
return a && b && a.m + a.f > 0 && b.m + b.f > 0;
|
||||
});
|
||||
|
||||
const countA = remaining.filter(c => c.parentA === parentA || c.parentB === parentA).length;
|
||||
const countB = remaining.filter(c => c.parentA === parentB || c.parentB === parentB).length;
|
||||
|
||||
const allocAm = Math.floor(sA.m / countA);
|
||||
const allocAf = Math.floor(sA.f / countA);
|
||||
const allocBm = Math.floor(sB.m / countB);
|
||||
const allocBf = Math.floor(sB.f / countB);
|
||||
|
||||
const c1 = Math.min(allocAm, allocBf); // ♂A × ♀B
|
||||
const c2 = Math.min(allocAf, allocBm); // ♀A × ♂B
|
||||
const bred = c1 + c2;
|
||||
|
||||
if (bred > 0) {
|
||||
sA.m -= c1; sB.f -= c1;
|
||||
sA.f -= c2; sB.m -= c2;
|
||||
|
||||
crossings.push({
|
||||
parentA, parentB, baby, gen: g, count: bred,
|
||||
pAMale: c1, pAFemale: c2,
|
||||
pBMale: c2, pBFemale: c1,
|
||||
});
|
||||
|
||||
if (!stock[baby]) stock[baby] = { m: 0, f: 0 };
|
||||
const halfM = Math.ceil(bred / 2);
|
||||
stock[baby].m += halfM;
|
||||
stock[baby].f += bred - halfM;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unusedStock = Object.entries(stock)
|
||||
.filter(([, s]) => s.m > 0 || s.f > 0)
|
||||
.map(([race, s]) => ({ race, m: s.m, f: s.f }));
|
||||
|
||||
return { crossings, unusedStock };
|
||||
}
|
||||
32
src/domain/services/XpCalculator.ts
Executable file
32
src/domain/services/XpCalculator.ts
Executable file
@ -0,0 +1,32 @@
|
||||
import { timeToGain } from '@domain/services/GaugeCalculator';
|
||||
import { xpForLevel } from '@domain/value-objects/XpTable';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
|
||||
export interface XpEtaInput {
|
||||
currentLevel: number;
|
||||
target: number | null;
|
||||
gaugeLevels: Readonly<Record<string, number>>;
|
||||
activeGauges: readonly string[];
|
||||
}
|
||||
|
||||
export interface XpEtaResult {
|
||||
done: boolean;
|
||||
seconds: number;
|
||||
needsGauge?: GaugeType;
|
||||
}
|
||||
|
||||
export class XpCalculator {
|
||||
computeEta(input: XpEtaInput): XpEtaResult {
|
||||
const { currentLevel, target, gaugeLevels, activeGauges } = input;
|
||||
if (target === null || target === undefined) return { done: false, seconds: 0 };
|
||||
if (currentLevel >= target) return { done: true, seconds: 0 };
|
||||
if (!activeGauges.includes('mangeoire')) return { done: false, seconds: Infinity, needsGauge: 'mangeoire' };
|
||||
|
||||
const gl = gaugeLevels['mangeoire'] ?? 0;
|
||||
const xpNeeded = Math.max(0, xpForLevel(target) - xpForLevel(currentLevel));
|
||||
if (xpNeeded <= 0) return { done: true, seconds: 0 };
|
||||
|
||||
const sec = timeToGain(gl, xpNeeded);
|
||||
return { done: false, seconds: sec };
|
||||
}
|
||||
}
|
||||
49
src/domain/value-objects/GaugeType.ts
Executable file
49
src/domain/value-objects/GaugeType.ts
Executable file
@ -0,0 +1,49 @@
|
||||
export type GaugeType = 'baffeur' | 'caresseur' | 'foudroyeur' | 'abreuvoir' | 'dragofesse' | 'mangeoire';
|
||||
export type StatType = 'serenite' | 'endurance' | 'maturite' | 'amour' | 'xp';
|
||||
|
||||
export interface GaugeDef {
|
||||
readonly label: string;
|
||||
readonly icon: string;
|
||||
readonly cssVar: string;
|
||||
readonly stat: StatType;
|
||||
readonly dir: -1 | 1;
|
||||
readonly isXp?: true;
|
||||
}
|
||||
|
||||
export const GAUGE_DEFS: Readonly<Record<GaugeType, GaugeDef>> = {
|
||||
baffeur: { label: 'Baffeur', icon: '➖', cssVar: '--ser', stat: 'serenite', dir: -1 },
|
||||
caresseur: { label: 'Caresseur', icon: '➕', cssVar: '--ser', stat: 'serenite', dir: 1 },
|
||||
foudroyeur: { label: 'Foudroyeur', icon: '⚡', cssVar: '--end', stat: 'endurance', dir: 1 },
|
||||
abreuvoir: { label: 'Abreuvoir', icon: '💧', cssVar: '--mat', stat: 'maturite', dir: 1 },
|
||||
dragofesse: { label: 'Dragofesse', icon: '❤', cssVar: '--amour', stat: 'amour', dir: 1 },
|
||||
mangeoire: { label: 'Mangeoire', icon: '🍖', cssVar: '--xp', stat: 'xp', dir: 1, isXp: true },
|
||||
};
|
||||
|
||||
export interface StatDef {
|
||||
readonly label: string;
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
readonly cssVar: string;
|
||||
readonly isLevel?: true;
|
||||
}
|
||||
|
||||
export const STAT_DEFS: Readonly<Record<StatType, StatDef>> = {
|
||||
serenite: { label: 'Sérénité', min: -5000, max: 5000, cssVar: '--ser' },
|
||||
endurance: { label: 'Endurance', min: 0, max: 20000, cssVar: '--end' },
|
||||
maturite: { label: 'Maturité', min: 0, max: 20000, cssVar: '--mat' },
|
||||
amour: { label: 'Amour', min: 0, max: 20000, cssVar: '--amour' },
|
||||
xp: { label: 'Niveau', min: 1, max: 200, cssVar: '--xp', isLevel: true },
|
||||
};
|
||||
|
||||
export const DEFAULT_TARGETS: Readonly<Record<GaugeType, number>> = {
|
||||
baffeur: -5000, caresseur: 40, foudroyeur: 20000, abreuvoir: 20000, dragofesse: 20000, mangeoire: 100,
|
||||
};
|
||||
|
||||
export function targetRange(gid: GaugeType): { min: number; max: number } {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
if (def.isXp) return { min: 1, max: 200 };
|
||||
const sd = STAT_DEFS[def.stat];
|
||||
if (def.dir < 0) return { min: sd.min, max: 0 };
|
||||
if (def.dir > 0 && sd.min < 0) return { min: 0, max: sd.max };
|
||||
return { min: sd.min, max: sd.max };
|
||||
}
|
||||
1
src/domain/value-objects/Gender.ts
Executable file
1
src/domain/value-objects/Gender.ts
Executable file
@ -0,0 +1 @@
|
||||
export type Gender = 'm' | 'f' | 'n';
|
||||
257
src/domain/value-objects/Race.ts
Executable file
257
src/domain/value-objects/Race.ts
Executable file
@ -0,0 +1,257 @@
|
||||
// ══════════════════════════════════════════
|
||||
// Race Value Object — extracted from index.html
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
export interface RaceData {
|
||||
name: string;
|
||||
stats: string[];
|
||||
parents: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// Generation → color
|
||||
export const GEN_COLORS: Record<number, string> = {
|
||||
1: '#c8622a',
|
||||
2: '#e8b820',
|
||||
3: '#6040b0',
|
||||
4: '#2a8acc',
|
||||
5: '#c03050',
|
||||
6: '#d040a0',
|
||||
7: '#c8c0a0',
|
||||
8: '#20a8b0',
|
||||
9: '#28a058',
|
||||
10: '#8050a0',
|
||||
};
|
||||
|
||||
// Base race → primary color
|
||||
export const RACE_BASE_COLORS: Record<string, string> = {
|
||||
'Rousse': '#c8622a',
|
||||
'Amande': '#d4b48a',
|
||||
'Dorée': '#e8b820',
|
||||
'Ebène': '#2a2a2a',
|
||||
'Indigo': '#6040b0',
|
||||
'Pourpre': '#c03050',
|
||||
'Orchidée': '#d040a0',
|
||||
'Ivoire': '#c8c0a0',
|
||||
'Turquoise': '#20a8b0',
|
||||
'Emeraude': '#28a058',
|
||||
'Prune': '#8050a0',
|
||||
};
|
||||
|
||||
// All race definitions by generation
|
||||
export const RACES_DATA: Record<number, RaceData[]> = {
|
||||
2: [
|
||||
{ name: 'Amande et Rousse', stats: ['400 Vitalité', '60 Soins', '1200 Initiative'], parents: 'Amande + Rousse', icon: '🐦' },
|
||||
{ name: 'Dorée et Rousse', stats: ['400 Vitalité', '1 Invocation', '45 Soins'], parents: 'Dorée + Rousse', icon: '🐦' },
|
||||
{ name: 'Amande et Dorée', stats: ['400 Vitalité', '1 Invocation', '1200 Initiative'], parents: 'Amande + Dorée', icon: '🐦' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Ebène', stats: ['400 Vitalité', '120 Agilité'], parents: 'Amande et Dorée + Dorée et Rousse', icon: '🐦' },
|
||||
{ name: 'Indigo', stats: ['400 Vitalité', '120 Chance'], parents: 'Amande et Dorée + Amande et Rousse', icon: '🐦' },
|
||||
],
|
||||
4: [
|
||||
{ name: 'Indigo et Rousse', stats: ['400 Vitalité', '90 Chance', '45 Soins'], parents: 'Indigo + Rousse', icon: '🐦' },
|
||||
{ name: 'Ebène et Rousse', stats: ['400 Vitalité', '90 Agilité', '45 Soins'], parents: 'Ebène + Rousse', icon: '🐦' },
|
||||
{ name: 'Amande et Indigo', stats: ['400 Vitalité', '90 Chance', '1200 Initiative'], parents: 'Amande + Indigo', icon: '🐦' },
|
||||
{ name: 'Amande et Ebène', stats: ['400 Vitalité', '120 Agilité', '1200 Initiative'], parents: 'Amande + Ebène', icon: '🐦' },
|
||||
{ name: 'Dorée et Indigo', stats: ['400 Vitalité', '90 Chance', '1 Invocation'], parents: 'Dorée + Indigo', icon: '🐦' },
|
||||
{ name: 'Dorée et Ebène', stats: ['400 Vitalité', '90 Agilité', '1 Invocation'], parents: 'Dorée + Ebène', icon: '🐦' },
|
||||
{ name: 'Ebène et Indigo', stats: ['400 Vitalité', '90 Chance', '90 Agilité'], parents: 'Ebène + Indigo', icon: '🐦' },
|
||||
],
|
||||
5: [
|
||||
{ name: 'Pourpre', stats: ['400 Vitalité', '120 Force'], parents: 'Ebène et Indigo + Amande et Rousse', icon: '🐦' },
|
||||
{ name: 'Orchidée', stats: ['400 Vitalité', '120 Intelligence'], parents: 'Ebène et Indigo + Dorée et Rousse', icon: '🐦' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Pourpre et Rousse', stats: ['400 Vitalité', '90 Force', '45 Soins'], parents: 'Pourpre + Rousse', icon: '🐦' },
|
||||
{ name: 'Orchidée et Rousse', stats: ['400 Vitalité', '90 Intelligence', '45 Soins'], parents: 'Orchidée + Rousse', icon: '🐦' },
|
||||
{ name: 'Amande et Pourpre', stats: ['400 Vitalité', '90 Force', '1200 Initiative'], parents: 'Amande + Pourpre', icon: '🐦' },
|
||||
{ name: 'Amande et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1200 Initiative'], parents: 'Amande + Orchidée', icon: '🐦' },
|
||||
{ name: 'Dorée et Pourpre', stats: ['400 Vitalité', '90 Force', '1 Invocation'], parents: 'Dorée + Pourpre', icon: '🐦' },
|
||||
{ name: 'Dorée et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1 Invocation'], parents: 'Dorée + Orchidée', icon: '🐦' },
|
||||
{ name: 'Indigo et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Chance'], parents: 'Indigo + Pourpre', icon: '🐦' },
|
||||
{ name: 'Indigo et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '90 Chance'], parents: 'Indigo + Orchidée', icon: '🐦' },
|
||||
{ name: 'Ebène et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Agilité'], parents: 'Ebène + Pourpre', icon: '🐦' },
|
||||
{ name: 'Ebène et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '90 Agilité'], parents: 'Ebène + Orchidée', icon: '🐦' },
|
||||
{ name: 'Orchidée et Pourpre', stats: ['400 Vitalité', '90 Force', '90 Intelligence'], parents: 'Orchidée + Pourpre', icon: '🐦' },
|
||||
],
|
||||
7: [
|
||||
{ name: 'Ivoire', stats: ['400 Vitalité', '90 Puissance'], parents: 'Orchidée et Pourpre + Indigo et Pourpre', icon: '🐦' },
|
||||
{ name: 'Turquoise', stats: ['400 Vitalité', '90 Prospection'], parents: 'Orchidée et Pourpre + Ebène et Orchidée', icon: '🐦' },
|
||||
],
|
||||
8: [
|
||||
{ name: 'Ivoire et Rousse', stats: ['400 Vitalité', '70 Puissance', '45 Soins'], parents: 'Ivoire + Rousse', icon: '🐦' },
|
||||
{ name: 'Turquoise et Rousse', stats: ['400 Vitalité', '45 Soins', '70 Prospection'], parents: 'Turquoise + Rousse', icon: '🐦' },
|
||||
{ name: 'Amande et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1200 Initiative'], parents: 'Amande + Ivoire', icon: '🐦' },
|
||||
{ name: 'Amande et Turquoise', stats: ['400 Vitalité', '70 Prospection', '1200 Initiative'], parents: 'Amande + Turquoise', icon: '🐦' },
|
||||
{ name: 'Dorée et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1 Invocation'], parents: 'Dorée + Ivoire', icon: '🐦' },
|
||||
{ name: 'Dorée et Turquoise', stats: ['400 Vitalité', '1 Invocation', '70 Prospection'], parents: 'Dorée + Turquoise', icon: '🐦' },
|
||||
{ name: 'Indigo et Ivoire', stats: ['400 Vitalité', '90 Chance', '70 Puissance'], parents: 'Indigo + Ivoire', icon: '🐦' },
|
||||
{ name: 'Indigo et Turquoise', stats: ['400 Vitalité', '90 Chance', '70 Prospection'], parents: 'Indigo + Turquoise', icon: '🐦' },
|
||||
{ name: 'Ebène et Ivoire', stats: ['400 Vitalité', '90 Agilité', '70 Puissance'], parents: 'Ebène + Ivoire', icon: '🐦' },
|
||||
{ name: 'Ebène et Turquoise', stats: ['400 Vitalité', '90 Agilité', '70 Prospection'], parents: 'Ebène + Turquoise', icon: '🐦' },
|
||||
{ name: 'Ivoire et Pourpre', stats: ['400 Vitalité', '90 Force', '70 Puissance'], parents: 'Ivoire + Pourpre', icon: '🐦' },
|
||||
{ name: 'Turquoise et Pourpre', stats: ['400 Vitalité', '90 Force', '70 Prospection'], parents: 'Turquoise + Pourpre', icon: '🐦' },
|
||||
{ name: 'Ivoire et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '70 Puissance'], parents: 'Ivoire + Orchidée', icon: '🐦' },
|
||||
{ name: 'Turquoise et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '70 Prospection'], parents: 'Turquoise + Orchidée', icon: '🐦' },
|
||||
{ name: 'Ivoire et Turquoise', stats: ['400 Vitalité', '70 Puissance', '70 Prospection'], parents: 'Ivoire + Turquoise', icon: '🐦' },
|
||||
],
|
||||
9: [
|
||||
{ name: 'Emeraude', stats: ['400 Vitalité', '14% Critique'], parents: 'Ivoire et Turquoise + Ivoire et Pourpre', icon: '🐦' },
|
||||
{ name: 'Prune', stats: ['400 Vitalité', '2 Portée'], parents: 'Ivoire et Turquoise + Turquoise et Orchidée', icon: '🐦' },
|
||||
],
|
||||
10: [
|
||||
{ name: 'Emeraude et Rousse', stats: ['400 Vitalité', '10% Critique', '45 Soins'], parents: 'Emeraude + Rousse', icon: '🐦' },
|
||||
{ name: 'Prune et Rousse', stats: ['400 Vitalité', '1 Portée', '45 Soins'], parents: 'Prune + Rousse', icon: '🐦' },
|
||||
{ name: 'Amande et Emeraude', stats: ['400 Vitalité', '10% Critique', '1200 Initiative'], parents: 'Amande + Emeraude', icon: '🐦' },
|
||||
{ name: 'Prune et Amande', stats: ['400 Vitalité', '1 Portée', '1200 Initiative'], parents: 'Prune + Amande', icon: '🐦' },
|
||||
{ name: 'Dorée et Emeraude', stats: ['400 Vitalité', '10% Critique', '1 Invocation'], parents: 'Dorée + Emeraude', icon: '🐦' },
|
||||
{ name: 'Prune et Dorée', stats: ['400 Vitalité', '1 Portée', '1 Invocation'], parents: 'Prune + Dorée', icon: '🐦' },
|
||||
{ name: 'Emeraude et Indigo', stats: ['400 Vitalité', '90 Chance', '10% Critique'], parents: 'Emeraude + Indigo', icon: '🐦' },
|
||||
{ name: 'Prune et Indigo', stats: ['400 Vitalité', '90 Chance', '1 Portée'], parents: 'Prune + Indigo', icon: '🐦' },
|
||||
{ name: 'Ebène et Emeraude', stats: ['400 Vitalité', '90 Agilité', '10% Critique'], parents: 'Ebène + Emeraude', icon: '🐦' },
|
||||
{ name: 'Prune et Ebène', stats: ['400 Vitalité', '90 Agilité', '1 Portée'], parents: 'Prune + Ebène', icon: '🐦' },
|
||||
{ name: 'Emeraude et Pourpre', stats: ['400 Vitalité', '90 Force', '10% Critique'], parents: 'Emeraude + Pourpre', icon: '🐦' },
|
||||
{ name: 'Prune et Pourpre', stats: ['400 Vitalité', '90 Force', '1 Portée'], parents: 'Prune + Pourpre', icon: '🐦' },
|
||||
{ name: 'Emeraude et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '10% Critique'], parents: 'Emeraude + Orchidée', icon: '🐦' },
|
||||
{ name: 'Prune et Orchidée', stats: ['400 Vitalité', '90 Intelligence', '1 Portée'], parents: 'Prune + Orchidée', icon: '🐦' },
|
||||
{ name: 'Emeraude et Ivoire', stats: ['400 Vitalité', '70 Puissance', '10% Critique'], parents: 'Emeraude + Ivoire', icon: '🐦' },
|
||||
{ name: 'Prune et Ivoire', stats: ['400 Vitalité', '70 Puissance', '1 Portée'], parents: 'Prune + Ivoire', icon: '🐦' },
|
||||
{ name: 'Emeraude et Turquoise', stats: ['400 Vitalité', '10% Critique', '70 Prospection'], parents: 'Emeraude + Turquoise', icon: '🐦' },
|
||||
{ name: 'Prune et Turquoise', stats: ['400 Vitalité', '1 Portée', '70 Prospection'], parents: 'Prune + Turquoise', icon: '🐦' },
|
||||
{ name: 'Prune et Emeraude', stats: ['400 Vitalité', '10% Critique', '1 Portée'], parents: 'Prune + Emeraude', icon: '🐦' },
|
||||
],
|
||||
};
|
||||
|
||||
// Breeding recipes: baby race → [parentA, parentB]
|
||||
export const BREEDING_RECIPES: Record<string, [string, string]> = {
|
||||
'Dorée et Rousse': ['Rousse', 'Dorée'],
|
||||
'Amande et Dorée': ['Amande', 'Dorée'],
|
||||
'Amande et Rousse': ['Amande', 'Rousse'],
|
||||
'Ebène': ['Amande et Dorée', 'Dorée et Rousse'],
|
||||
'Indigo': ['Amande et Dorée', 'Amande et Rousse'],
|
||||
'Indigo et Rousse': ['Indigo', 'Rousse'],
|
||||
'Ebène et Rousse': ['Ebène', 'Rousse'],
|
||||
'Amande et Indigo': ['Amande', 'Indigo'],
|
||||
'Amande et Ebène': ['Amande', 'Ebène'],
|
||||
'Dorée et Indigo': ['Dorée', 'Indigo'],
|
||||
'Dorée et Ebène': ['Dorée', 'Ebène'],
|
||||
'Ebène et Indigo': ['Ebène', 'Indigo'],
|
||||
'Pourpre': ['Ebène et Indigo', 'Amande et Rousse'],
|
||||
'Orchidée': ['Ebène et Indigo', 'Dorée et Rousse'],
|
||||
'Pourpre et Rousse': ['Pourpre', 'Rousse'],
|
||||
'Orchidée et Rousse': ['Orchidée', 'Rousse'],
|
||||
'Amande et Pourpre': ['Amande', 'Pourpre'],
|
||||
'Amande et Orchidée': ['Amande', 'Orchidée'],
|
||||
'Dorée et Pourpre': ['Dorée', 'Pourpre'],
|
||||
'Dorée et Orchidée': ['Dorée', 'Orchidée'],
|
||||
'Indigo et Pourpre': ['Indigo', 'Pourpre'],
|
||||
'Indigo et Orchidée': ['Indigo', 'Orchidée'],
|
||||
'Ebène et Pourpre': ['Ebène', 'Pourpre'],
|
||||
'Ebène et Orchidée': ['Ebène', 'Orchidée'],
|
||||
'Orchidée et Pourpre': ['Orchidée', 'Pourpre'],
|
||||
'Ivoire': ['Orchidée et Pourpre', 'Indigo et Pourpre'],
|
||||
'Turquoise': ['Orchidée et Pourpre', 'Ebène et Orchidée'],
|
||||
'Ivoire et Rousse': ['Ivoire', 'Rousse'],
|
||||
'Turquoise et Rousse': ['Turquoise', 'Rousse'],
|
||||
'Amande et Ivoire': ['Amande', 'Ivoire'],
|
||||
'Amande et Turquoise': ['Amande', 'Turquoise'],
|
||||
'Dorée et Ivoire': ['Dorée', 'Ivoire'],
|
||||
'Dorée et Turquoise': ['Dorée', 'Turquoise'],
|
||||
'Indigo et Ivoire': ['Indigo', 'Ivoire'],
|
||||
'Indigo et Turquoise': ['Indigo', 'Turquoise'],
|
||||
'Ebène et Ivoire': ['Ebène', 'Ivoire'],
|
||||
'Ebène et Turquoise': ['Ebène', 'Turquoise'],
|
||||
'Ivoire et Pourpre': ['Ivoire', 'Pourpre'],
|
||||
'Turquoise et Pourpre': ['Turquoise', 'Pourpre'],
|
||||
'Ivoire et Orchidée': ['Ivoire', 'Orchidée'],
|
||||
'Turquoise et Orchidée': ['Turquoise', 'Orchidée'],
|
||||
'Ivoire et Turquoise': ['Ivoire', 'Turquoise'],
|
||||
'Emeraude': ['Ivoire et Turquoise', 'Ivoire et Pourpre'],
|
||||
'Prune': ['Ivoire et Turquoise', 'Turquoise et Orchidée'],
|
||||
'Emeraude et Rousse': ['Emeraude', 'Rousse'],
|
||||
'Prune et Rousse': ['Prune', 'Rousse'],
|
||||
'Amande et Emeraude': ['Amande', 'Emeraude'],
|
||||
'Prune et Amande': ['Prune', 'Amande'],
|
||||
'Dorée et Emeraude': ['Dorée', 'Emeraude'],
|
||||
'Prune et Dorée': ['Prune', 'Dorée'],
|
||||
'Emeraude et Indigo': ['Emeraude', 'Indigo'],
|
||||
'Prune et Indigo': ['Prune', 'Indigo'],
|
||||
'Ebène et Emeraude': ['Ebène', 'Emeraude'],
|
||||
'Prune et Ebène': ['Prune', 'Ebène'],
|
||||
'Emeraude et Pourpre': ['Emeraude', 'Pourpre'],
|
||||
'Prune et Pourpre': ['Prune', 'Pourpre'],
|
||||
'Emeraude et Orchidée': ['Emeraude', 'Orchidée'],
|
||||
'Prune et Orchidée': ['Prune', 'Orchidée'],
|
||||
'Emeraude et Ivoire': ['Emeraude', 'Ivoire'],
|
||||
'Prune et Ivoire': ['Prune', 'Ivoire'],
|
||||
'Emeraude et Turquoise': ['Emeraude', 'Turquoise'],
|
||||
'Prune et Turquoise': ['Prune', 'Turquoise'],
|
||||
'Prune et Emeraude': ['Prune', 'Emeraude'],
|
||||
};
|
||||
|
||||
// Race name → generation number (computed)
|
||||
export const RACE_GEN: Record<string, number> = {};
|
||||
['Rousse', 'Dorée', 'Amande'].forEach((n) => (RACE_GEN[n] = 1));
|
||||
Object.entries(RACES_DATA).forEach(([g, rs]) =>
|
||||
rs.forEach((r) => (RACE_GEN[r.name] = parseInt(g))),
|
||||
);
|
||||
|
||||
// Reverse lookup: "ParentA|ParentB" → baby race
|
||||
export const BREEDING_BY_PARENTS: Record<string, string> = {};
|
||||
Object.entries(BREEDING_RECIPES).forEach(([baby, [a, b]]) => {
|
||||
BREEDING_BY_PARENTS[a + '|' + b] = baby;
|
||||
if (a !== b) BREEDING_BY_PARENTS[b + '|' + a] = baby;
|
||||
});
|
||||
|
||||
// For a given parent, which partners are possible?
|
||||
export const COMPATIBLE_PARTNERS: Record<
|
||||
string,
|
||||
{ partner: string; baby: string; gen: number }[]
|
||||
> = {};
|
||||
Object.entries(BREEDING_RECIPES).forEach(([baby, [a, b]]) => {
|
||||
if (!COMPATIBLE_PARTNERS[a]) COMPATIBLE_PARTNERS[a] = [];
|
||||
COMPATIBLE_PARTNERS[a].push({ partner: b, baby, gen: RACE_GEN[baby] });
|
||||
if (a !== b) {
|
||||
if (!COMPATIBLE_PARTNERS[b]) COMPATIBLE_PARTNERS[b] = [];
|
||||
COMPATIBLE_PARTNERS[b].push({ partner: a, baby, gen: RACE_GEN[baby] });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
const COLOR_ORDER = [
|
||||
'Emeraude', 'Prune', 'Ivoire', 'Turquoise',
|
||||
'Orchidée', 'Pourpre', 'Indigo', 'Ebène',
|
||||
'Dorée', 'Amande', 'Rousse',
|
||||
];
|
||||
|
||||
/** Returns the generation number for a race name. */
|
||||
export function generationOf(name: string): number {
|
||||
return RACE_GEN[name] ?? 0;
|
||||
}
|
||||
|
||||
/** Returns true if the race is a base (gen 1) race. */
|
||||
export function isBaseRace(name: string): boolean {
|
||||
return RACE_GEN[name] === 1;
|
||||
}
|
||||
|
||||
/** Returns the primary color for a race name. */
|
||||
export function raceColor(name: string): string {
|
||||
for (const k of COLOR_ORDER) {
|
||||
if (name.includes(k)) return RACE_BASE_COLORS[k];
|
||||
}
|
||||
return '#888';
|
||||
}
|
||||
|
||||
/** Returns the secondary color for gradient, or null if none. */
|
||||
export function raceColor2(name: string): string | null {
|
||||
let found = false;
|
||||
for (const k of COLOR_ORDER) {
|
||||
if (name.includes(k)) {
|
||||
if (found) return RACE_BASE_COLORS[k];
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
14
src/domain/value-objects/Tier.ts
Executable file
14
src/domain/value-objects/Tier.ts
Executable file
@ -0,0 +1,14 @@
|
||||
export const TIER_THRESHOLDS = [
|
||||
{ lo: 90000, rate: 40, num: 4 },
|
||||
{ lo: 70000, rate: 30, num: 3 },
|
||||
{ lo: 40000, rate: 20, num: 2 },
|
||||
{ lo: 0, rate: 10, num: 1 },
|
||||
] as const;
|
||||
|
||||
export function tierRate(level: number): number {
|
||||
return level > 90000 ? 40 : level > 70000 ? 30 : level > 40000 ? 20 : 10;
|
||||
}
|
||||
|
||||
export function tierNum(level: number): number {
|
||||
return level > 90000 ? 4 : level > 70000 ? 3 : level > 40000 ? 2 : 1;
|
||||
}
|
||||
16
src/domain/value-objects/XpTable.ts
Executable file
16
src/domain/value-objects/XpTable.ts
Executable file
@ -0,0 +1,16 @@
|
||||
// Copy of XP_RAW from src/index.html line 357
|
||||
export const XP_RAW: Readonly<Record<number, number>> = {
|
||||
1:0,2:19,3:49,4:96,5:161,6:246,7:353,8:481,9:633,10:809,11:1011,12:1238,13:1491,14:1772,15:2081,16:2419,17:2786,18:3182,19:3609,20:4067,21:4557,22:5078,23:5632,24:6219,25:6839,26:7493,27:8182,28:8905,29:9664,30:10457,31:11287,32:12154,33:13057,34:13997,35:14974,36:15990,37:17043,38:18135,39:19266,40:20437,41:21646,42:22896,43:24186,44:25516,45:26887,46:28299,47:29753,48:31248,49:32785,50:34365,51:35987,52:37652,53:39360,54:41111,55:42906,56:44745,57:46628,58:48555,59:50527,60:52544,61:54607,62:56714,63:58868,64:61067,65:63312,66:65604,67:67942,68:70327,69:72760,70:75239,71:77766,72:80341,73:82964,74:85635,75:88355,76:91123,77:93940,78:96806,79:99721,80:102685,81:105700,82:108764,83:111878,84:115042,85:118257,86:121523,87:124840,88:128207,89:131626,90:135096,91:138618,92:142191,93:145817,94:149495,95:153225,96:157008,97:160843,98:164732,99:168673,100:172668,101:176716,102:180818,103:184974,104:189183,105:193447,106:197765,107:202137,108:206565,109:211046,110:215583,111:220176,112:224823,113:229526,114:234284,115:239099,116:243969,117:248895,118:253878,119:258917,120:264013,121:269165,122:274375,123:279641,124:284965,125:290346,126:295784,127:301280,128:306834,129:312446,130:318116,131:323845,132:329631,133:335477,134:341381,135:347343,136:353365,137:359446,138:365587,139:371786,140:378045,141:384364,142:390743,143:397182,144:403681,145:410240,146:416859,147:423539,148:430280,149:437082,150:443944,151:450868,152:457852,153:464898,154:472006,155:479175,156:486406,157:493699,158:501054,159:508470,160:515950,161:523491,162:531095,163:538762,164:546491,165:554283,166:562139,167:570057,168:578039,169:586084,170:594193,171:602365,172:610601,173:618901,174:627265,175:635693,176:644185,177:652742,178:661363,179:670049,180:678799,181:687615,182:696495,183:705440,184:714451,185:723527,186:732668,187:741875,188:751148,189:760486,190:769890,191:779361,192:788897,193:798500,194:808169,195:817904,196:827706,197:837575,198:847510,199:857513,200:867582,
|
||||
};
|
||||
|
||||
export function xpForLevel(lvl: number): number {
|
||||
return XP_RAW[Math.min(200, Math.max(1, Math.round(lvl)))] ?? 0;
|
||||
}
|
||||
|
||||
export function levelFromXp(xp: number): number {
|
||||
if (xp >= XP_RAW[200]!) return 200;
|
||||
for (let i = 199; i >= 1; i--) {
|
||||
if (XP_RAW[i]! <= xp) return i;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
3132
src/index.html
Normal file → Executable file
3132
src/index.html
Normal file → Executable file
File diff suppressed because one or more lines are too long
74
src/infrastructure/alarm/WebAudioAlarm.ts
Executable file
74
src/infrastructure/alarm/WebAudioAlarm.ts
Executable file
@ -0,0 +1,74 @@
|
||||
import type { AlarmPort } from '@domain/ports/AlarmPort';
|
||||
|
||||
export class WebAudioAlarm implements AlarmPort {
|
||||
private audioCtx: AudioContext | null = null;
|
||||
|
||||
private ensureContext(): AudioContext {
|
||||
if (!this.audioCtx) {
|
||||
this.audioCtx = new AudioContext();
|
||||
}
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
this.audioCtx.resume();
|
||||
}
|
||||
return this.audioCtx;
|
||||
}
|
||||
|
||||
play(soundName: string): void {
|
||||
const ctx = this.ensureContext();
|
||||
const doPlay = () => {
|
||||
if (soundName === 'arpege') {
|
||||
[440, 554, 659, 880].forEach((f, i) => setTimeout(() => {
|
||||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = f; o.type = 'sine';
|
||||
g.gain.setValueAtTime(0.35, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55);
|
||||
o.start(); o.stop(ctx.currentTime + 0.55);
|
||||
}, i * 140));
|
||||
} else if (soundName === 'pulse') {
|
||||
[0, 200, 400, 600, 800].forEach(ms => setTimeout(() => {
|
||||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = 880; o.type = 'square';
|
||||
g.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
|
||||
o.start(); o.stop(ctx.currentTime + 0.15);
|
||||
}, ms));
|
||||
} else if (soundName === 'fanfare') {
|
||||
([[523, 0], [659, 150], [784, 300], [1047, 500], [784, 700], [1047, 900]] as [number, number][]).forEach(([f, ms]) => setTimeout(() => {
|
||||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = f; o.type = 'triangle';
|
||||
g.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
|
||||
o.start(); o.stop(ctx.currentTime + 0.18);
|
||||
}, ms));
|
||||
} else if (soundName === 'cloche') {
|
||||
const t = ctx.currentTime;
|
||||
([[440, 1], [880, 0.6], [1320, 0.4], [1760, 0.25]] as [number, number][]).forEach(([f, v], i) => {
|
||||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = f; o.type = 'sine';
|
||||
g.gain.setValueAtTime(v * 0.3, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + 2.5);
|
||||
o.start(t + i * 0.01); o.stop(t + 2.5);
|
||||
});
|
||||
} else {
|
||||
// Fallback : arpege
|
||||
this.play('arpege');
|
||||
}
|
||||
};
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume().then(doPlay).catch(() => {});
|
||||
} else {
|
||||
doPlay();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.audioCtx) {
|
||||
this.audioCtx.close();
|
||||
this.audioCtx = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
449
src/infrastructure/electron/main.ts
Executable file
449
src/infrastructure/electron/main.ts
Executable file
@ -0,0 +1,449 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
ipcMain,
|
||||
Notification,
|
||||
dialog,
|
||||
} from 'electron';
|
||||
import path from 'path';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
// ─── NOM DE L'APPLICATION ─────────────────────────────────────────────────────
|
||||
app.setName('Obsidienne');
|
||||
// Windows utilise l'AppUserModelId pour le nom affiché dans les notifications
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('Obsidienne');
|
||||
}
|
||||
|
||||
// ─── MODE DEV / E2E ────────────────────────────<E29480><E29480>─────────────────────────────
|
||||
// En E2E (Playwright), utiliser un dossier userData dédié
|
||||
if (process.env.ELECTRON_USER_DATA_DIR) {
|
||||
app.setPath('userData', process.env.ELECTRON_USER_DATA_DIR);
|
||||
} else if (!app.isPackaged) {
|
||||
// En dev (npm start), les données sont isolées de l'app installée
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'Obsidienne-DEV'));
|
||||
}
|
||||
|
||||
// ─── MIGRATION DONNÉES (ancien nom → Obsidienne) ────────────────────────────
|
||||
// Les utilisateurs de "Minuteur Dragodinde" conservent leurs données après le renommage
|
||||
if (app.isPackaged) {
|
||||
const oldDataFile = path.join(app.getPath('appData'), 'Minuteur Dragodinde', 'dd-timer-data.json');
|
||||
const newDataFile = path.join(app.getPath('userData'), 'dd-timer-data.json');
|
||||
const shouldMigrate = (() => {
|
||||
if (!fs.existsSync(oldDataFile)) return false;
|
||||
if (!fs.existsSync(newDataFile)) return true;
|
||||
// Si le nouveau fichier est quasi-vide (<200 octets = état par défaut), on remigre
|
||||
try {
|
||||
const newSize = fs.statSync(newDataFile).size;
|
||||
const oldSize = fs.statSync(oldDataFile).size;
|
||||
return newSize < 200 && oldSize > newSize;
|
||||
} catch { return false; }
|
||||
})();
|
||||
if (shouldMigrate) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(newDataFile), { recursive: true });
|
||||
fs.copyFileSync(oldDataFile, newDataFile);
|
||||
console.log('Migration données: Minuteur Dragodinde → Obsidienne OK');
|
||||
} catch (e: unknown) {
|
||||
console.error('Migration données échouée:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CONFIG GITEA ─────────────────────────────────────────────────────────────
|
||||
const GITEA_HOST = 'gitea.mickael-pol.fr'; // ton instance Gitea
|
||||
const GITEA_USER = 'mickael'; // ton user Gitea
|
||||
const GITEA_REPO = 'dd-timer'; // ton repo
|
||||
const CURRENT_VERSION: string = app.getVersion(); // lu depuis package.json
|
||||
|
||||
interface UpdateInfo {
|
||||
version: string;
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
let updateInfo: UpdateInfo | null = null;
|
||||
let updateCheckInProgress = false;
|
||||
let updateDownloaded = false;
|
||||
|
||||
// ─── ICÔNE ───────────────────────────────────────────────────────────────────
|
||||
function getAppIcon(): Electron.NativeImage {
|
||||
const iconPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'icon.ico')
|
||||
: path.join(__dirname, '../icon.ico');
|
||||
if (fs.existsSync(iconPath)) return nativeImage.createFromPath(iconPath);
|
||||
return nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
// ─── FENÊTRE ─────────────────────────────────────────────────────────────────
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280, height: 900, minWidth: 960, minHeight: 650,
|
||||
title: 'Obsidienne - Dofus 3',
|
||||
icon: getAppIcon(),
|
||||
backgroundColor: '#0b0b14',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
backgroundThrottling: false,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
},
|
||||
});
|
||||
// Dev: Vite dev server; Prod: built renderer in dist-vite/
|
||||
if (process.env['VITE_DEV_SERVER_URL']) {
|
||||
mainWindow.loadURL(process.env['VITE_DEV_SERVER_URL']);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../dist-vite/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting) {
|
||||
e.preventDefault();
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: 'question',
|
||||
buttons: ['Minimiser', 'Quitter'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: 'Obsidienne',
|
||||
message: 'Que souhaites-tu faire ?',
|
||||
detail: 'Minimiser garde l\'app en arriere-plan.\nLes alarmes continueront de sonner.',
|
||||
});
|
||||
if (choice === 1) {
|
||||
if (tray) { tray.destroy(); tray = null; }
|
||||
process.exit(0);
|
||||
} else {
|
||||
mainWindow!.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom clavier : Ctrl+Plus, Ctrl+Minus, Ctrl+0
|
||||
mainWindow.webContents.on('before-input-event', (_e, input) => {
|
||||
if (!input.control || input.type !== 'keyDown') return;
|
||||
const wc = mainWindow!.webContents;
|
||||
if (input.key === '+' || input.key === '=') {
|
||||
wc.setZoomLevel(wc.getZoomLevel() + 0.5);
|
||||
} else if (input.key === '-') {
|
||||
wc.setZoomLevel(wc.getZoomLevel() - 0.5);
|
||||
} else if (input.key === '0') {
|
||||
wc.setZoomLevel(0);
|
||||
}
|
||||
});
|
||||
|
||||
// Envoyer les infos de version au renderer une fois chargé
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow!.webContents.send('app-version', CURRENT_VERSION);
|
||||
if (updateInfo) {
|
||||
mainWindow!.webContents.send('update-available', updateInfo);
|
||||
}
|
||||
// Badge DEV visible dans l'interface (via IPC, pas executeJavaScript)
|
||||
if (!app.isPackaged) {
|
||||
mainWindow!.webContents.send('dev-mode');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── TRAY ─────────────────────────────────────────────────────────────────────
|
||||
function createTray(): void {
|
||||
tray = new Tray(getAppIcon());
|
||||
tray.setToolTip(`Obsidienne v${CURRENT_VERSION}`);
|
||||
rebuildTrayMenu();
|
||||
tray.on('double-click', () => { mainWindow!.show(); mainWindow!.focus(); });
|
||||
}
|
||||
|
||||
function rebuildTrayMenu(): void {
|
||||
if (!tray) return;
|
||||
const items: Electron.MenuItemConstructorOptions[] = [
|
||||
{ label: `Obsidienne v${CURRENT_VERSION}`, enabled: false },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Ouvrir', click: () => { mainWindow!.show(); mainWindow!.focus(); } },
|
||||
];
|
||||
if (updateInfo) {
|
||||
items.push({ type: 'separator' });
|
||||
if (updateDownloaded) {
|
||||
items.push({
|
||||
label: `⬆ Installer v${updateInfo.version} maintenant`,
|
||||
click: () => autoUpdater.quitAndInstall(true, true),
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: `⏳ Téléchargement v${updateInfo.version}...`,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: 'Quitter', click: () => { isQuitting = true; app.quit(); } });
|
||||
tray.setContextMenu(Menu.buildFromTemplate(items));
|
||||
}
|
||||
|
||||
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
|
||||
function fireNotification(title: string, body: string): void {
|
||||
if (!Notification.isSupported()) return;
|
||||
const n = new Notification({ title, body, timeoutType: 'never' });
|
||||
n.on('click', () => { mainWindow!.show(); mainWindow!.focus(); });
|
||||
n.show();
|
||||
}
|
||||
|
||||
// ─── IPC ──────────────────────────────────────────────────────────────────────
|
||||
ipcMain.on('trigger-alarm', (_event, { enclosName }: { enclosName: string }) => {
|
||||
fireNotification('Dragodindes pretes !', enclosName + ' - Toutes les cibles ont ete atteintes !');
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('play-alarm-sound');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('show-notification', (_event, { title, body }: { title: string; body: string }) => {
|
||||
fireNotification(title, body);
|
||||
});
|
||||
|
||||
// Dialogue de confirmation natif (remplace confirm() du renderer qui casse les inputs)
|
||||
ipcMain.handle('show-confirm', (_event, { title, message, detail }: { title: string; message: string; detail: string }) => {
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: 'question',
|
||||
buttons: ['Annuler', 'Confirmer'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
title: title || 'Confirmation',
|
||||
message: message || '',
|
||||
detail: detail || '',
|
||||
});
|
||||
return choice === 1;
|
||||
});
|
||||
|
||||
// ─── SAUVEGARDE FICHIER (persistante entre mises à jour) ─────────────────
|
||||
const dataFile: string = path.join(app.getPath('userData'), 'dd-timer-data.json');
|
||||
|
||||
ipcMain.handle('load-data', () => {
|
||||
try {
|
||||
if (fs.existsSync(dataFile)) return fs.readFileSync(dataFile, 'utf-8');
|
||||
} catch (e: unknown) { console.error('load-data error:', (e as Error).message); }
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on('save-data', (_event, json: string) => {
|
||||
try {
|
||||
fs.writeFileSync(dataFile, json, 'utf-8');
|
||||
} catch (e: unknown) { console.error('save-data error:', (e as Error).message); }
|
||||
});
|
||||
|
||||
// ─── NTFY (notifications mobiles) ─────────────────────────────────────────
|
||||
ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title: string; message: string }) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
if (parsed.protocol !== 'https:') {
|
||||
console.warn('ntfy: HTTPS requis, requête ignorée');
|
||||
return;
|
||||
}
|
||||
// API JSON ntfy : supporte nativement l'UTF-8 (accents, emojis)
|
||||
const topic = parsed.pathname.replace(/^\//, '');
|
||||
const jsonBody = JSON.stringify({
|
||||
topic,
|
||||
title,
|
||||
message,
|
||||
priority: 4,
|
||||
tags: ['dragon'],
|
||||
});
|
||||
const options: https.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: '/',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(jsonBody, 'utf-8'),
|
||||
},
|
||||
};
|
||||
const req = https.request(options, (res) => {
|
||||
res.on('data', () => {}); // drain
|
||||
res.on('end', () => {});
|
||||
});
|
||||
req.on('error', (e: Error) => console.warn('ntfy send error:', e.message));
|
||||
req.write(jsonBody, 'utf-8');
|
||||
req.end();
|
||||
} catch (e: unknown) {
|
||||
console.warn('ntfy error:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('focus-window', () => { mainWindow!.show(); mainWindow!.focus(); });
|
||||
|
||||
// Renderer demande à installer la mise à jour
|
||||
ipcMain.on('install-update', () => {
|
||||
if (updateDownloaded) {
|
||||
autoUpdater.quitAndInstall(true, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Renderer demande la version
|
||||
ipcMain.handle('get-version', () => CURRENT_VERSION);
|
||||
|
||||
// ─── EXPORT / IMPORT FICHIER ────────────────────────────────────────────────
|
||||
ipcMain.handle('export-file', async (_event, { data, defaultName }: { data: string; defaultName: string }) => {
|
||||
if (!mainWindow) return false;
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Exporter les plans',
|
||||
defaultPath: path.join(app.getPath('documents'), defaultName),
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return false;
|
||||
try {
|
||||
fs.writeFileSync(result.filePath, data, 'utf-8');
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
console.error('export-file error:', (e as Error).message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('import-file', async () => {
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Importer des plans',
|
||||
defaultPath: app.getPath('documents'),
|
||||
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
try {
|
||||
return fs.readFileSync(result.filePaths[0], 'utf-8');
|
||||
} catch (e: unknown) {
|
||||
console.error('import-file error:', (e as Error).message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
mainWindow.webContents.send('update-downloading', { version: info.version });
|
||||
}
|
||||
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', () => {
|
||||
updateDownloaded = true;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-ready');
|
||||
}
|
||||
rebuildTrayMenu();
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('Update error:', err.message);
|
||||
updateCheckInProgress = false;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update-error', { message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('electron-updater: no update found in latest.yml');
|
||||
updateCheckInProgress = false;
|
||||
});
|
||||
|
||||
// ─── VÉRIFICATION VIA API GITEA ─────────────────────────────────────────────
|
||||
function checkForUpdates(silent = false): void {
|
||||
if (updateCheckInProgress) return;
|
||||
updateCheckInProgress = true;
|
||||
const options: https.RequestOptions = {
|
||||
hostname: GITEA_HOST,
|
||||
port: 443,
|
||||
path: `/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases?limit=1`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': `Obsidienne/${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) { updateCheckInProgress = false; return; }
|
||||
const release = releases[0];
|
||||
const latestVersion = release.tag_name;
|
||||
if (!latestVersion || compareVersions(CURRENT_VERSION, latestVersion) <= 0) {
|
||||
updateCheckInProgress = false;
|
||||
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 sha512
|
||||
autoUpdater.checkForUpdates();
|
||||
} catch (e: unknown) {
|
||||
updateCheckInProgress = false;
|
||||
console.error('Update check parse error:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e: Error) => { updateCheckInProgress = false; console.error('Update check error:', e.message); });
|
||||
req.end();
|
||||
}
|
||||
|
||||
// ─── CYCLE DE VIE ────────────────────────────────────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
// Vérifier les mises à jour uniquement au démarrage (silencieux)
|
||||
setTimeout(() => checkForUpdates(true), 3000);
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform === 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => { isQuitting = true; });
|
||||
37
src/infrastructure/electron/preload.ts
Executable file
37
src/infrastructure/electron/preload.ts
Executable file
@ -0,0 +1,37 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
isElectron: true,
|
||||
|
||||
// Sauvegarde persistante (fichier JSON dans userData)
|
||||
saveData: (json: string) => ipcRenderer.send('save-data', json),
|
||||
loadData: () => ipcRenderer.invoke('load-data'),
|
||||
|
||||
// Alarme
|
||||
triggerAlarm: (enclosName: string) => ipcRenderer.send('trigger-alarm', { enclosName }),
|
||||
showNotification: (title: string, body: string) => ipcRenderer.send('show-notification', { title, body }),
|
||||
sendNtfy: (url: string, title: string, message: string) => ipcRenderer.send('send-ntfy', { url, title, message }),
|
||||
focusWindow: () => ipcRenderer.send('focus-window'),
|
||||
showConfirm: (title: string, message: string, detail: string) => ipcRenderer.invoke('show-confirm', { title, message, detail }),
|
||||
onPlayAlarmSound: (cb: () => void) => ipcRenderer.on('play-alarm-sound', () => cb()),
|
||||
|
||||
// Export / Import fichier
|
||||
exportFile: (data: string, defaultName: string) => ipcRenderer.invoke('export-file', { data, defaultName }),
|
||||
importFile: () => ipcRenderer.invoke('import-file'),
|
||||
|
||||
// Version
|
||||
getVersion: () => ipcRenderer.invoke('get-version'),
|
||||
onAppVersion: (cb: (v: string) => void) => ipcRenderer.on('app-version', (_e, v) => cb(v)),
|
||||
|
||||
// Mises à jour
|
||||
installUpdate: () => ipcRenderer.send('install-update'),
|
||||
onUpdateAvailable: (cb: (info: any) => void) => ipcRenderer.on('update-available', (_e, info) => cb(info)),
|
||||
onUpdateNotAvailable: (cb: () => void) => ipcRenderer.on('update-not-available', () => cb()),
|
||||
onUpdateDownloading: (cb: (info: any) => void) => ipcRenderer.on('update-downloading', (_e, info) => cb(info)),
|
||||
onUpdateProgress: (cb: (info: any) => void) => ipcRenderer.on('update-progress', (_e, info) => cb(info)),
|
||||
onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', () => cb()),
|
||||
onUpdateError: (cb: (info: any) => void) => ipcRenderer.on('update-error', (_e, info) => cb(info)),
|
||||
|
||||
// Dev mode badge
|
||||
onDevMode: (cb: () => void) => ipcRenderer.on('dev-mode', () => cb()),
|
||||
});
|
||||
17
src/infrastructure/notifications/ElectronNotification.ts
Executable file
17
src/infrastructure/notifications/ElectronNotification.ts
Executable file
@ -0,0 +1,17 @@
|
||||
import type { NotificationPort } from '@domain/ports/NotificationPort';
|
||||
|
||||
export class ElectronNotification implements NotificationPort {
|
||||
showNotification(title: string, body: string): void {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.showNotification) {
|
||||
api.showNotification(title, body);
|
||||
}
|
||||
}
|
||||
|
||||
sendMobileNotification(url: string, title: string, message: string): void {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.sendNtfy) {
|
||||
api.sendNtfy(url, title, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/infrastructure/persistence/LocalStorageRepository.ts
Executable file
106
src/infrastructure/persistence/LocalStorageRepository.ts
Executable file
@ -0,0 +1,106 @@
|
||||
import type { AppState, StateRepository } from '@domain/ports/StateRepository';
|
||||
import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType';
|
||||
|
||||
interface ElectronAPI {
|
||||
saveData: (json: string) => void;
|
||||
loadData: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
function getElectronAPI(): ElectronAPI | null {
|
||||
if (typeof window !== 'undefined' && (window as any).electronAPI) {
|
||||
return (window as any).electronAPI;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class LocalStorageRepository implements StateRepository {
|
||||
private readonly storageKey = 'dd3v3';
|
||||
|
||||
async load(): Promise<AppState | null> {
|
||||
try {
|
||||
let raw: string | null = null;
|
||||
const api = getElectronAPI();
|
||||
if (api) raw = await api.loadData();
|
||||
if (!raw && typeof localStorage !== 'undefined') {
|
||||
raw = localStorage.getItem(this.storageKey) || localStorage.getItem('dd3v2');
|
||||
}
|
||||
if (!raw) return null;
|
||||
return this.deserialize(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
save(state: AppState): void {
|
||||
try {
|
||||
const d = JSON.parse(JSON.stringify(state));
|
||||
// Reset runtime state before persisting
|
||||
d.enclos.forEach((e: any) => {
|
||||
e.timer.running = false;
|
||||
e.alerted = {};
|
||||
});
|
||||
const json = JSON.stringify(d);
|
||||
const api = getElectronAPI();
|
||||
if (api) api.saveData(json);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(this.storageKey, json);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erreur sauvegarde:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private deserialize(raw: string): AppState {
|
||||
const d = JSON.parse(raw);
|
||||
const state: AppState = {
|
||||
enclos: d.enclos || [],
|
||||
activeId: d.activeId ?? null,
|
||||
nextEnclosId: d.nextEnclosId || 1,
|
||||
alarmSound: d.alarmSound || 'arpege',
|
||||
notifsEnabled: d.notifsEnabled !== undefined ? d.notifsEnabled : true,
|
||||
ntfyTopic: d.ntfyTopic || '',
|
||||
archivedStats: d.archivedStats || [],
|
||||
inventaire: d.inventaire || {},
|
||||
workflows: d.workflows || [],
|
||||
accouplements: d.accouplements || [],
|
||||
};
|
||||
|
||||
// Migration: old ntfyUrl format → ntfyTopic
|
||||
if (!state.ntfyTopic && d.ntfyUrl) {
|
||||
const m = d.ntfyUrl.match(/\/([^\/]+)$/);
|
||||
if (m) state.ntfyTopic = m[1];
|
||||
}
|
||||
|
||||
// Migrate enclos data
|
||||
state.enclos.forEach((enc: any) => {
|
||||
enc.timer = enc.timer || { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} };
|
||||
enc.timer.running = false;
|
||||
enc.alerted = {};
|
||||
if (enc.gaugeLevels.mangeoire === undefined) enc.gaugeLevels.mangeoire = 0;
|
||||
enc.dragodindes.forEach((dd: any) => {
|
||||
if (dd.stats.xp === undefined) dd.stats.xp = 1;
|
||||
// Migration: old serenite target → gauge-based targets
|
||||
if (dd.targets.serenite !== undefined && dd.targets.baffeur === undefined) {
|
||||
const old = { ...dd.targets };
|
||||
dd.targets = {
|
||||
baffeur: old.serenite ?? -5000,
|
||||
caresseur: Math.max(0, old.serenite ?? 40),
|
||||
foudroyeur: old.endurance ?? 20000,
|
||||
abreuvoir: old.maturite ?? 20000,
|
||||
dragofesse: old.amour ?? 20000,
|
||||
mangeoire: 100,
|
||||
};
|
||||
}
|
||||
Object.keys(DEFAULT_TARGETS).forEach(k => {
|
||||
if (dd.targets[k] === undefined) dd.targets[k] = (DEFAULT_TARGETS as any)[k];
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!state.activeId && state.enclos.length) {
|
||||
state.activeId = state.enclos[0].id;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
16
src/infrastructure/update/GiteaUpdateAdapter.ts
Executable file
16
src/infrastructure/update/GiteaUpdateAdapter.ts
Executable file
@ -0,0 +1,16 @@
|
||||
import type { UpdatePort, UpdateInfo } from '@domain/ports/UpdatePort';
|
||||
|
||||
export class GiteaUpdateAdapter implements UpdatePort {
|
||||
async checkForUpdates(): Promise<UpdateInfo | null> {
|
||||
// Delegated to main process via IPC in the actual Electron app
|
||||
// This is a placeholder — the real check happens in main.ts
|
||||
return null;
|
||||
}
|
||||
|
||||
downloadAndInstall(info: UpdateInfo): void {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.installUpdate) {
|
||||
api.installUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
478
src/presentation/components/AccouplementView.ts
Executable file
478
src/presentation/components/AccouplementView.ts
Executable file
@ -0,0 +1,478 @@
|
||||
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 } from '@domain/value-objects/Race';
|
||||
import { getDDImage } from '@presentation/helpers/dd-image';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
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;
|
||||
private dragScrollRAF: number | null = null;
|
||||
private dragMouseY = 0;
|
||||
|
||||
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 ── */
|
||||
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 (gen filter, search, partner compat) ── */
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Auto-scroll during drag ── */
|
||||
private getScrollContainer(): HTMLElement | null {
|
||||
return this.el?.closest('.main-content') as HTMLElement | null;
|
||||
}
|
||||
|
||||
private startDragAutoScroll(): void {
|
||||
const container = this.getScrollContainer();
|
||||
if (!container) return;
|
||||
|
||||
const onDragOver = (e: DragEvent) => { this.dragMouseY = e.clientY; };
|
||||
container.addEventListener('dragover', onDragOver);
|
||||
|
||||
const EDGE = 80; // zone de déclenchement (px depuis le bord)
|
||||
const SPEED = 28; // vitesse de scroll (px par frame)
|
||||
|
||||
const tick = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const y = this.dragMouseY;
|
||||
|
||||
if (y > 0 && y < rect.top + EDGE) {
|
||||
// Proche du haut → scroll vers le haut
|
||||
container.scrollTop -= SPEED;
|
||||
} else if (y > rect.bottom - EDGE && y < rect.bottom) {
|
||||
// Proche du bas → scroll vers le bas
|
||||
container.scrollTop += SPEED;
|
||||
}
|
||||
|
||||
this.dragScrollRAF = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
this.dragScrollRAF = requestAnimationFrame(tick);
|
||||
// Store cleanup ref
|
||||
(this as any)._dragOverCleanup = () => container.removeEventListener('dragover', onDragOver);
|
||||
}
|
||||
|
||||
private stopDragAutoScroll(): void {
|
||||
if (this.dragScrollRAF !== null) {
|
||||
cancelAnimationFrame(this.dragScrollRAF);
|
||||
this.dragScrollRAF = null;
|
||||
}
|
||||
if ((this as any)._dragOverCleanup) {
|
||||
(this as any)._dragOverCleanup();
|
||||
delete (this as any)._dragOverCleanup;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Assign a race to a parent slot ── */
|
||||
private assignRaceToSlot(race: string, forceSlot?: 1 | 2): void {
|
||||
const slot = forceSlot ?? (this.accoupState.parent1 ? 2 : 1);
|
||||
|
||||
if (slot === 1) {
|
||||
this.accoupState.parent1 = race;
|
||||
this.accoupState.parent2 = null; // Reset P2 since partners depend on P1
|
||||
this.accoupState.selectingSlot = 2;
|
||||
this.accoupState.filterGen = null;
|
||||
this.accoupState.search = '';
|
||||
} else {
|
||||
// Check partner compatibility if P1 is set
|
||||
if (this.accoupState.parent1) {
|
||||
const partners = COMPATIBLE_PARTNERS[this.accoupState.parent1] ?? [];
|
||||
const isCompat = partners.some(p => p.partner === race);
|
||||
if (!isCompat) return; // Ignore incompatible drop
|
||||
}
|
||||
this.accoupState.parent2 = race;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
this.update();
|
||||
}
|
||||
|
||||
/* ── Single page render ── */
|
||||
private renderSinglePage(): void {
|
||||
if (!this.el) return;
|
||||
const { parent1, parent2, filterGen, search, couples, babies } = 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>
|
||||
</div>`;
|
||||
if (parent1) {
|
||||
const gen1 = RACE_GEN[parent1] ?? 1;
|
||||
html += `<div class="accoup-selected-parent accoup-drop-zone" data-drop-slot="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 accoup-drop-zone" data-select-slot="1" data-drop-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 ou glisser un mâle ici</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>
|
||||
</div>`;
|
||||
if (parent2) {
|
||||
const gen2 = RACE_GEN[parent2] ?? 1;
|
||||
html += `<div class="accoup-selected-parent accoup-drop-zone" data-drop-slot="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 accoup-drop-zone" data-select-slot="2" data-drop-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 ou glisser une femelle ici</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 {
|
||||
html += `<div class="accoup-race-grid">`;
|
||||
for (const race of filtered) {
|
||||
const genCol = GEN_COLORS[race.gen] ?? '#888';
|
||||
html += `<div class="accoup-race-card" data-race="${esc(race.name)}" draggable="true">
|
||||
<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); }
|
||||
}
|
||||
}
|
||||
|
||||
private _rc = 0;
|
||||
private _rt: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private _hrc(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
this._rc++;
|
||||
if (this._rt) clearTimeout(this._rt);
|
||||
if (this._rc >= 3) {
|
||||
this._rc = 0;
|
||||
const a = new Audio(atob('c2Z4LXVpLm1wMw=='));
|
||||
a.volume = 0.7;
|
||||
a.play().catch(() => {});
|
||||
} else {
|
||||
this._rt = setTimeout(() => { this._rc = 0; }, 800);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Event binding ── */
|
||||
private bindEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
const _h = this.el.querySelector('.accoup-heart');
|
||||
if (_h) _h.addEventListener('contextmenu', (e) => this._hrc(e as MouseEvent));
|
||||
|
||||
// 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;
|
||||
});
|
||||
});
|
||||
|
||||
// Clear parent buttons
|
||||
this.el.querySelectorAll<HTMLElement>('.accoup-selected-parent-clear').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const slot = parseInt(btn.dataset.clear!);
|
||||
if (slot === 1) {
|
||||
this.accoupState.parent1 = null;
|
||||
this.accoupState.parent2 = null;
|
||||
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', () => {
|
||||
this.assignRaceToSlot(card.dataset.race!);
|
||||
});
|
||||
|
||||
// Drag start
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer!.setData('text/plain', card.dataset.race!);
|
||||
e.dataTransfer!.effectAllowed = 'copy';
|
||||
card.classList.add('dragging');
|
||||
this.startDragAutoScroll();
|
||||
});
|
||||
card.addEventListener('dragend', () => {
|
||||
card.classList.remove('dragging');
|
||||
this.stopDragAutoScroll();
|
||||
});
|
||||
});
|
||||
|
||||
// Drop zones
|
||||
this.el.querySelectorAll<HTMLElement>('.accoup-drop-zone').forEach(zone => {
|
||||
zone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'copy';
|
||||
});
|
||||
zone.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.add('drag-over');
|
||||
});
|
||||
zone.addEventListener('dragleave', () => {
|
||||
zone.classList.remove('drag-over');
|
||||
});
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('drag-over');
|
||||
const race = e.dataTransfer!.getData('text/plain');
|
||||
if (!race) return;
|
||||
const slot = parseInt(zone.dataset.dropSlot!) as 1 | 2;
|
||||
this.assignRaceToSlot(race, slot);
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
Toast.show('success', 'Accouplement enregistré.');
|
||||
|
||||
// Reset
|
||||
this.accoupState = {
|
||||
parent1: null, parent2: null,
|
||||
filterGen: null, search: '',
|
||||
couples: '', babies: '',
|
||||
selectingSlot: 1,
|
||||
};
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
394
src/presentation/components/App.ts
Executable file
394
src/presentation/components/App.ts
Executable file
@ -0,0 +1,394 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { UIState } from '@presentation/state/UIState';
|
||||
import type { DashboardResult } from '@application/queries/GetDashboard';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { EnclosView } from './EnclosView';
|
||||
import { AccouplementView } from './AccouplementView';
|
||||
import { ReapproView } from './ReapproView';
|
||||
import { InventaireView } from './InventaireView';
|
||||
import { ParametresView } from './ParametresView';
|
||||
import { WorkflowsView } from './WorkflowsView';
|
||||
import { StatistiquesView } from './StatistiquesView';
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { UndoManager } from '@presentation/services/UndoManager';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
import { enclosGlobalState } from '@presentation/helpers/gauge-live';
|
||||
import { MAX_ENCLOS } from '@domain/entities/Enclos';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
|
||||
type ChildComponent = { update(): void; destroy(): void };
|
||||
|
||||
export class App {
|
||||
private root: HTMLElement;
|
||||
private sidebar: Sidebar;
|
||||
private updateBanner: UpdateBanner;
|
||||
private activeChild: ChildComponent | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private rafId: number | null = null;
|
||||
private completionIntervalId: number | null = null;
|
||||
private ctrlZHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
private lastView: string | number | null = null;
|
||||
|
||||
// Tab drag-and-drop state
|
||||
private dragSrcIdx: number | null = null;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private uiState: UIState,
|
||||
rootElement: HTMLElement,
|
||||
private playSound?: (name: string) => void,
|
||||
) {
|
||||
this.root = rootElement;
|
||||
this.sidebar = new Sidebar(uiState, queryBus);
|
||||
this.updateBanner = new UpdateBanner();
|
||||
}
|
||||
|
||||
render(): void {
|
||||
this.root.innerHTML = `
|
||||
<div class="app-shell">
|
||||
<div id="sb-container"></div>
|
||||
<div class="main-area">
|
||||
<header class="app-header">
|
||||
<button class="app-hamburger" id="hamburger-btn">☰</button>
|
||||
<div class="app-header-text">
|
||||
<h1 class="app-title"><span class="app-title-icon">⚔</span> Obsidienne</h1>
|
||||
<p class="app-subtitle">Dofus 3 · Gestion multi-enclos en temps réel</p>
|
||||
</div>
|
||||
<div class="app-hamburger" style="visibility:hidden;pointer-events:none;" aria-hidden="true"></div>
|
||||
</header>
|
||||
<div id="update-banner-root"></div>
|
||||
<div class="tabs-row" id="tabs-row"></div>
|
||||
<div id="enclos-content" class="main-content custom-scrollbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Mount sidebar
|
||||
const sbContainer = this.root.querySelector('#sb-container') as HTMLElement;
|
||||
this.sidebar.render(sbContainer);
|
||||
|
||||
// Mount update banner
|
||||
const bannerRoot = this.root.querySelector('#update-banner-root') as HTMLElement;
|
||||
this.updateBanner.render(bannerRoot);
|
||||
|
||||
// Mount toast container
|
||||
const appShell = this.root.querySelector('.app-shell') as HTMLElement;
|
||||
Toast.mount(appShell);
|
||||
|
||||
// Ctrl+Z → undo
|
||||
this.ctrlZHandler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
|
||||
e.preventDefault();
|
||||
UndoManager.undo();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this.ctrlZHandler);
|
||||
|
||||
// DEV badge via IPC (pas d'executeJavaScript)
|
||||
const api = (window as any).electronAPI;
|
||||
api?.onDevMode?.(() => {
|
||||
const p = this.root.querySelector('header p');
|
||||
if (p && !document.getElementById('dev-badge')) {
|
||||
const b = document.createElement('span');
|
||||
b.id = 'dev-badge';
|
||||
b.textContent = 'DEV';
|
||||
b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle';
|
||||
p.appendChild(b);
|
||||
}
|
||||
});
|
||||
|
||||
// Hamburger toggle
|
||||
const hamburgerBtn = this.root.querySelector('#hamburger-btn') as HTMLElement;
|
||||
hamburgerBtn.addEventListener('click', () => this.uiState.toggleSidebar());
|
||||
|
||||
// Subscribe to UI state changes
|
||||
this.unsubscribe = this.uiState.subscribe(() => this.onStateChange());
|
||||
|
||||
// Initial renders
|
||||
this.renderTabs();
|
||||
this.renderContent();
|
||||
this.updateSidebarState();
|
||||
|
||||
// Start animation loop
|
||||
this.startAnimationLoop();
|
||||
|
||||
// Interval indépendant du focus fenêtre pour la détection de fin de session
|
||||
this.completionIntervalId = window.setInterval(() => {
|
||||
this.checkAllEnclosCompletion();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private onStateChange(): void {
|
||||
this.renderTabs();
|
||||
this.renderContent();
|
||||
this.sidebar.update();
|
||||
this.updateSidebarState();
|
||||
}
|
||||
|
||||
private updateSidebarState(): void {
|
||||
const sidebarEl = this.root.querySelector('.sidebar-new') as HTMLElement | null;
|
||||
if (sidebarEl) {
|
||||
sidebarEl.classList.toggle('sidebar-closed', !this.uiState.sidebarOpen);
|
||||
}
|
||||
}
|
||||
|
||||
private getDashboardData(): DashboardResult {
|
||||
return this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────
|
||||
private renderTabs(): void {
|
||||
const tabsRow = this.root.querySelector('#tabs-row') as HTMLElement | null;
|
||||
if (!tabsRow) return;
|
||||
|
||||
const data = this.getDashboardData();
|
||||
const enclosList = data.enclosSummaries;
|
||||
const activeView = this.uiState.activeView;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Dashboard tab
|
||||
const dashActive = activeView === 'dashboard' ? ' active' : '';
|
||||
html += `<div class="tab${dashActive}" data-view="dashboard"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">dashboard</span>Dashboard</span></div>`;
|
||||
|
||||
// Statistiques tab
|
||||
const statsActive = activeView === 'statistiques' ? ' active' : '';
|
||||
html += `<div class="tab${statsActive}" data-view="statistiques"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">bar_chart</span>Statistiques</span></div>`;
|
||||
|
||||
// Enclos tabs
|
||||
enclosList.forEach((enc, idx) => {
|
||||
const isActive = activeView === enc.id ? ' active' : '';
|
||||
const isRunning = enc.running ? ' running' : '';
|
||||
const canDelete = enclosList.length > 1;
|
||||
html += `<div class="tab${isActive}${isRunning}" draggable="true" data-idx="${idx}" data-view="${enc.id}" id="tab-enc-${enc.id}">`;
|
||||
html += `<span class="tab-dot"></span>`;
|
||||
html += `<span>${esc(enc.name)}</span>`;
|
||||
if (canDelete) {
|
||||
html += `<button class="tab-del" data-delete-id="${enc.id}" title="Supprimer">✕</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
// Add enclos button
|
||||
const disabled = enclosList.length >= MAX_ENCLOS ? ' disabled' : '';
|
||||
html += `<button class="add-tab" id="add-enclos-btn"${disabled}>+ Enclos</button>`;
|
||||
|
||||
tabsRow.innerHTML = html;
|
||||
|
||||
// Tab click events
|
||||
tabsRow.querySelectorAll('.tab[data-view]').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('tab-del')) return;
|
||||
const view = (tab as HTMLElement).dataset['view']!;
|
||||
const viewValue = /^\d+$/.test(view) ? Number(view) : view;
|
||||
this.uiState.setActiveView(viewValue);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete events
|
||||
tabsRow.querySelectorAll('.tab-del').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = Number((btn as HTMLElement).dataset['deleteId']);
|
||||
const ok = await ConfirmModal.show('Supprimer l\'enclos', 'Cette action est irréversible. Continuer ?');
|
||||
if (!ok) return;
|
||||
const hasSnap = await UndoManager.snapshotCurrent('Suppression enclos');
|
||||
this.commandBus.execute({ type: 'delete-enclos', enclosId: id });
|
||||
Toast.show('success', 'Enclos supprimé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
|
||||
if (this.uiState.activeView === id) {
|
||||
const newData = this.getDashboardData();
|
||||
const firstEnclos = newData.enclosSummaries[0];
|
||||
this.uiState.setActiveView(firstEnclos ? firstEnclos.id : 'dashboard');
|
||||
} else {
|
||||
this.uiState.notify();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add enclos button
|
||||
const addBtn = tabsRow.querySelector('#add-enclos-btn') as HTMLElement | null;
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'create-enclos' });
|
||||
const newData = this.getDashboardData();
|
||||
const last = newData.enclosSummaries[newData.enclosSummaries.length - 1];
|
||||
if (last) this.uiState.setActiveView(last.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag and drop
|
||||
this.setupTabDragAndDrop(tabsRow);
|
||||
}
|
||||
|
||||
private setupTabDragAndDrop(tabsRow: HTMLElement): void {
|
||||
const tabs = tabsRow.querySelectorAll('.tab[draggable=true]') as NodeListOf<HTMLElement>;
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('dragstart', (e) => {
|
||||
this.dragSrcIdx = Number(tab.dataset['idx']);
|
||||
(e as DragEvent).dataTransfer!.effectAllowed = 'move';
|
||||
tab.classList.add('dragging');
|
||||
});
|
||||
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
(e as DragEvent).dataTransfer!.dropEffect = 'move';
|
||||
tab.classList.add('drag-over');
|
||||
});
|
||||
|
||||
tab.addEventListener('dragleave', () => {
|
||||
tab.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
tab.addEventListener('dragend', () => {
|
||||
tab.classList.remove('dragging');
|
||||
tabs.forEach(t => t.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
tab.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
tab.classList.remove('drag-over');
|
||||
const destIdx = Number(tab.dataset['idx']);
|
||||
if (this.dragSrcIdx !== null && this.dragSrcIdx !== destIdx) {
|
||||
this.commandBus.execute({
|
||||
type: 'reorder-enclos',
|
||||
fromIndex: this.dragSrcIdx,
|
||||
toIndex: destIdx,
|
||||
});
|
||||
this.uiState.notify();
|
||||
}
|
||||
this.dragSrcIdx = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Content routing ───────────────────────────────────────────
|
||||
private renderContent(): void {
|
||||
const view = this.uiState.activeView;
|
||||
|
||||
if (view === this.lastView && this.activeChild) {
|
||||
this.activeChild.update();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeChild) {
|
||||
this.activeChild.destroy();
|
||||
this.activeChild = null;
|
||||
}
|
||||
|
||||
const container = this.root.querySelector('#enclos-content') as HTMLElement | null;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
this.lastView = view;
|
||||
|
||||
if (view === 'dashboard') {
|
||||
const child = new Dashboard(this.commandBus, this.queryBus, this.uiState);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'accouplement') {
|
||||
const child = new AccouplementView(this.commandBus, this.queryBus);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'appro') {
|
||||
const child = new ReapproView(this.commandBus, this.queryBus);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'inventaire') {
|
||||
const child = new InventaireView(this.commandBus, this.queryBus);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'workflows') {
|
||||
const child = new WorkflowsView(this.commandBus, this.queryBus, this.uiState);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'statistiques') {
|
||||
const child = new StatistiquesView(this.commandBus, this.queryBus);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (view === 'parametres') {
|
||||
const child = new ParametresView(this.commandBus, this.queryBus, this.playSound);
|
||||
child.render(container);
|
||||
this.activeChild = child;
|
||||
} else if (typeof view === 'number') {
|
||||
const child = new EnclosView(this.commandBus, this.queryBus, this.uiState);
|
||||
child.render(container, view);
|
||||
this.activeChild = child;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live update loop ──────────────────────────────────────────
|
||||
private lastRafTime = 0;
|
||||
|
||||
private startAnimationLoop(): void {
|
||||
const loop = (now: number) => {
|
||||
// Throttle à ~4fps quand aucun timer ne tourne (idle)
|
||||
const data = this.getDashboardData();
|
||||
const anyRunning = data.enclosSummaries.some(e => e.running);
|
||||
if (!anyRunning && now - this.lastRafTime < 250) {
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
this.lastRafTime = now;
|
||||
this.updateTabDots();
|
||||
if (this.activeChild) this.activeChild.update();
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
this.rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
private updateTabDots(): void {
|
||||
const data = this.getDashboardData();
|
||||
data.enclosSummaries.forEach(enc => {
|
||||
const tab = this.root.querySelector(`#tab-enc-${enc.id}`) as HTMLElement | null;
|
||||
if (!tab) return;
|
||||
tab.classList.toggle('running', enc.running);
|
||||
});
|
||||
}
|
||||
|
||||
/** Appelle complete-timer sur tout enclos dont toutes les cibles sont atteintes. */
|
||||
private checkAllEnclosCompletion(): void {
|
||||
const data = this.getDashboardData();
|
||||
data.enclosSummaries.forEach(summary => {
|
||||
if (!summary.running) return;
|
||||
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: summary.id });
|
||||
if (!enc.dragodindes.length || !enc.activeGauges.length) return;
|
||||
const { allDone } = enclosGlobalState(enc);
|
||||
if (allDone) {
|
||||
this.commandBus.execute({ type: 'complete-timer', enclosId: summary.id });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
if (this.completionIntervalId !== null) {
|
||||
clearInterval(this.completionIntervalId);
|
||||
this.completionIntervalId = null;
|
||||
}
|
||||
if (this.ctrlZHandler) {
|
||||
document.removeEventListener('keydown', this.ctrlZHandler);
|
||||
this.ctrlZHandler = null;
|
||||
}
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
if (this.activeChild) {
|
||||
this.activeChild.destroy();
|
||||
this.activeChild = null;
|
||||
}
|
||||
this.sidebar.destroy();
|
||||
this.updateBanner.destroy();
|
||||
this.root.innerHTML = '';
|
||||
}
|
||||
}
|
||||
79
src/presentation/components/ConfirmModal.ts
Executable file
79
src/presentation/components/ConfirmModal.ts
Executable file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Modale de confirmation glassmorphism.
|
||||
*
|
||||
* Remplace les confirm() / electronAPI.showConfirm() natifs
|
||||
* par une modale cohérente avec le design Obsidienne.
|
||||
*/
|
||||
|
||||
let overlay: HTMLElement | null = null;
|
||||
|
||||
function ensureOverlay(): HTMLElement {
|
||||
if (overlay && overlay.parentNode) return overlay;
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay confirm-hidden';
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
export const ConfirmModal = {
|
||||
/**
|
||||
* Affiche une modale de confirmation.
|
||||
* @returns true si l'utilisateur confirme, false sinon.
|
||||
*/
|
||||
show(title: string, message: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const ov = ensureOverlay();
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'confirm-box';
|
||||
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.className = 'confirm-icon material-symbols-outlined';
|
||||
iconEl.textContent = 'warning';
|
||||
|
||||
const titleEl = document.createElement('h3');
|
||||
titleEl.className = 'confirm-title';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.className = 'confirm-msg';
|
||||
msgEl.textContent = message;
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'confirm-footer';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'confirm-btn confirm-btn-cancel';
|
||||
cancelBtn.textContent = 'Annuler';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'confirm-btn confirm-btn-ok';
|
||||
okBtn.textContent = 'Confirmer';
|
||||
|
||||
footer.appendChild(cancelBtn);
|
||||
footer.appendChild(okBtn);
|
||||
box.appendChild(iconEl);
|
||||
box.appendChild(titleEl);
|
||||
box.appendChild(msgEl);
|
||||
box.appendChild(footer);
|
||||
ov.innerHTML = '';
|
||||
ov.appendChild(box);
|
||||
|
||||
ov.classList.remove('confirm-hidden');
|
||||
|
||||
const close = (result: boolean) => {
|
||||
ov.classList.add('confirm-hidden');
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener('click', () => close(false), { once: true });
|
||||
okBtn.addEventListener('click', () => close(true), { once: true });
|
||||
ov.addEventListener('click', (e) => {
|
||||
if (e.target === ov) close(false);
|
||||
}, { once: true });
|
||||
|
||||
// Focus sur le bouton Annuler pour éviter les confirmations accidentelles
|
||||
requestAnimationFrame(() => cancelBtn.focus());
|
||||
});
|
||||
},
|
||||
};
|
||||
254
src/presentation/components/Dashboard.ts
Executable file
254
src/presentation/components/Dashboard.ts
Executable file
@ -0,0 +1,254 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { UIState } from '@presentation/state/UIState';
|
||||
import type { DashboardResult } from '@application/queries/GetDashboard';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
import { MAX_DD } from '@domain/entities/Enclos';
|
||||
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { raceColor } from '@domain/value-objects/Race';
|
||||
import { enclosGlobalState, enclosGaugeCurGl } from '@presentation/helpers/gauge-live';
|
||||
import { esc, fmtClock } from '@presentation/helpers/format';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { UndoManager } from '@presentation/services/UndoManager';
|
||||
|
||||
export class Dashboard {
|
||||
private el: HTMLElement | null = null;
|
||||
private lastRenderTime = 0;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private uiState: UIState,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'dash-new';
|
||||
container.appendChild(this.el);
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
private getData(): DashboardResult {
|
||||
return this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
|
||||
}
|
||||
|
||||
private renderAll(): void {
|
||||
if (!this.el) return;
|
||||
const data = this.getData();
|
||||
|
||||
// ── KPI Section ──────────────────────────────────────────────
|
||||
const activeDD = data.enclosSummaries.reduce((s, e) => s + e.ddCount, 0);
|
||||
const racesObtained = Object.keys(data.raceBreakdown).length;
|
||||
const kpis = [
|
||||
{ label: 'Total Bébés', value: String(data.totalBabies) },
|
||||
{ label: 'Dragodindes Actives', value: String(activeDD) },
|
||||
{ label: 'Couples Formés', value: String(data.totalCouples) },
|
||||
{ label: 'Taux de Réussite', value: `${data.successRate}%` },
|
||||
{ label: 'Races Obtenues', value: String(racesObtained) },
|
||||
];
|
||||
|
||||
let kpiHtml = `
|
||||
<section>
|
||||
<div class="dash-section-hd">
|
||||
<span class="dash-section-lbl">Statistiques Globales</span>
|
||||
<button class="dash-reset-btn-new" id="dash-reset-btn">
|
||||
<span class="material-symbols-outlined">restart_alt</span>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
<div class="dash-kpi-grid-new">
|
||||
`;
|
||||
for (const kpi of kpis) {
|
||||
kpiHtml += `
|
||||
<div class="dash-kpi-card">
|
||||
<p class="dash-kpi-lbl">${esc(kpi.label)}</p>
|
||||
<span class="dash-kpi-val">${esc(kpi.value)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
kpiHtml += `</div></section>`;
|
||||
|
||||
// ── Two-column section ───────────────────────────────────────
|
||||
let enclosHtml = `
|
||||
<section>
|
||||
<div class="dash-section-hd">
|
||||
<span class="dash-section-lbl">Aperçu — Tous les enclos</span>
|
||||
</div>
|
||||
<div class="dash-enc-grid">
|
||||
`;
|
||||
|
||||
for (const summary of data.enclosSummaries) {
|
||||
const enc = this.queryBus.execute<Enclos | null>({ type: 'get-enclos-detail', enclosId: summary.id });
|
||||
const gs = enc ? enclosGlobalState(enc) : null;
|
||||
const started = !!enc?.timer.startTime;
|
||||
const running = !!enc?.timer.running;
|
||||
const allDone = !!gs?.allDone && started;
|
||||
|
||||
// Status
|
||||
let statusClass = 'idle';
|
||||
let statusLabel = 'Inactif';
|
||||
if (running) { statusClass = 'running'; statusLabel = 'Actif'; }
|
||||
else if (started) { statusClass = 'paused'; statusLabel = 'Pause'; }
|
||||
|
||||
const cardClass = `dash-enc-card${running ? ' running' : ''}${allDone ? ' done-enc' : ''}`;
|
||||
|
||||
// Gauge tags — détecte les jauges vides en cours de session
|
||||
const gaugeTags = summary.activeGauges.map(gid => {
|
||||
const def = GAUGE_DEFS[gid as keyof typeof GAUGE_DEFS];
|
||||
if (!def) return '';
|
||||
const cssVar = `var(${def.cssVar})`;
|
||||
const curGl = (enc && started) ? enclosGaugeCurGl(enc, gid as any) : (enc?.gaugeLevels[gid as keyof typeof enc.gaugeLevels] ?? 0);
|
||||
const isEmpty = curGl <= 0 && started;
|
||||
if (isEmpty) {
|
||||
return `<span class="dash-enc-gauge-tag dash-gauge-empty"
|
||||
style="background:rgba(234,179,8,0.12);border-color:rgba(234,179,8,0.3);color:#eab308">
|
||||
⚠ ${esc(def.label)}
|
||||
</span>`;
|
||||
}
|
||||
return `<span class="dash-enc-gauge-tag"
|
||||
style="background:color-mix(in srgb, ${cssVar} 10%, transparent);border-color:color-mix(in srgb, ${cssVar} 30%, transparent);color:${cssVar}">
|
||||
${def.icon} ${esc(def.label)}
|
||||
</span>`;
|
||||
}).join('');
|
||||
const gaugesRow = summary.activeGauges.length > 0
|
||||
? `<div class="dash-enc-gauges-row">${gaugeTags}</div>`
|
||||
: `<div class="dash-enc-gauges-row"><span class="dash-enc-no-gauge">Aucune jauge active</span></div>`;
|
||||
|
||||
// Sous-label capacité max (uniquement quand l'enclos est plein)
|
||||
const capaciteLabel = summary.ddCount >= MAX_DD ? 'Capacit\u00e9 max' : '';
|
||||
const cdText = started && gs ? (gs.allDone ? '✅' : (!isFinite(gs.globalMax) ? '∞' : fmtClock(gs.globalMax))) : '--:--:--';
|
||||
const elText = started && gs ? fmtClock(gs.el) : '--:--:--';
|
||||
|
||||
// Button style: active = primary button if running and approaching end
|
||||
const btnClass = allDone ? 'dash-enc-btn btn-active' : 'dash-enc-btn';
|
||||
|
||||
enclosHtml += `
|
||||
<div class="${cardClass}" id="dash-enc-${summary.id}" data-enc-id="${summary.id}">
|
||||
<div class="dash-enc-header-row">
|
||||
<span class="dash-enc-name-new">${esc(summary.name.toUpperCase())}</span>
|
||||
<div class="dash-enc-status-badge ${statusClass}">
|
||||
<span class="dash-enc-dot ${statusClass}"></span>
|
||||
${esc(statusLabel)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-enc-meta-row">
|
||||
<div class="dash-enc-dd-block">
|
||||
<div class="dd-count-big">${summary.ddCount} <span style="font-family:'Inter',sans-serif;font-size:14px;color:rgba(176,168,182,0.6);font-weight:500;margin-left:4px">DD</span></div>
|
||||
${capaciteLabel ? `<div class="dd-count-sub">${capaciteLabel}</div>` : ''}
|
||||
</div>
|
||||
<div class="dash-enc-times">
|
||||
<div class="dash-enc-time-row primary">
|
||||
<span class="material-symbols-outlined">hourglass_top</span>
|
||||
Restant : <span id="dash-cd-${summary.id}">${cdText}</span>
|
||||
</div>
|
||||
<div class="dash-enc-time-row muted">
|
||||
<span class="material-symbols-outlined">schedule</span>
|
||||
Écoulé : <span id="dash-el-${summary.id}">${elText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${gaugesRow}
|
||||
|
||||
<button class="${btnClass}" data-enc-id="${summary.id}">
|
||||
Gérer cet enclos
|
||||
<span class="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
enclosHtml += `</div></section>`;
|
||||
|
||||
// ── Race progression panel (right col) ───────────────────────
|
||||
const raceEntries = Object.entries(data.raceBreakdown).sort((a, b) => b[1] - a[1]);
|
||||
const maxCount = raceEntries.length > 0 ? raceEntries[0][1] : 1;
|
||||
|
||||
let raceHtml = `
|
||||
<div>
|
||||
<div class="dash-section-hd">
|
||||
<span class="dash-section-lbl">Progression des races</span>
|
||||
</div>
|
||||
<div class="dash-race-panel">
|
||||
`;
|
||||
|
||||
if (raceEntries.length === 0) {
|
||||
raceHtml += `<p class="dash-race-empty">Aucune race enregistrée</p>`;
|
||||
} else {
|
||||
for (const [race, count] of raceEntries) {
|
||||
const pct = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||
const col = raceColor(race);
|
||||
raceHtml += `
|
||||
<div class="dash-race-row">
|
||||
<div class="dash-race-row-hd">
|
||||
<span style="color:${col}">${esc(race)}</span>
|
||||
<span style="color:rgba(176,168,182,0.7)">${count}</span>
|
||||
</div>
|
||||
<div class="dash-race-bar-bg">
|
||||
<div class="dash-race-bar-fill" style="width:${pct}%;background:${col}"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
raceHtml += `</div></div>`;
|
||||
|
||||
// ── Assemble ──────────────────────────────────────────────────
|
||||
this.el.innerHTML =
|
||||
kpiHtml +
|
||||
`<div class="dash-two-col">` +
|
||||
`<div>${enclosHtml}</div>` +
|
||||
raceHtml +
|
||||
`</div>`;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// "Gérer" buttons — navigate with stopPropagation
|
||||
this.el.querySelectorAll<HTMLElement>('button[data-enc-id]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const id = Number(btn.dataset['encId']);
|
||||
if (id) this.uiState.setActiveView(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Card click (excluding button area)
|
||||
this.el.querySelectorAll<HTMLElement>('.dash-enc-card[id^="dash-enc-"]').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
const id = Number(card.id.replace('dash-enc-', ''));
|
||||
if (id) this.uiState.setActiveView(id);
|
||||
});
|
||||
});
|
||||
|
||||
// Reset stats
|
||||
this.el.querySelector('#dash-reset-btn')?.addEventListener('click', async () => {
|
||||
const ok = await ConfirmModal.show('Réinitialiser les statistiques', 'Toutes les statistiques seront effacées. Continuer ?');
|
||||
if (!ok) return;
|
||||
const hasSnap = await UndoManager.snapshotCurrent('Reset statistiques');
|
||||
this.commandBus.execute({ type: 'reset-stats' });
|
||||
Toast.show('success', 'Statistiques réinitialisées.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
|
||||
this.renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.el) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastRenderTime >= 1000) {
|
||||
this.lastRenderTime = now;
|
||||
this.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
466
src/presentation/components/DragodindeCard.ts
Executable file
466
src/presentation/components/DragodindeCard.ts
Executable file
@ -0,0 +1,466 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
import type { Dragodinde } from '@domain/entities/Dragodinde';
|
||||
import type { GaugeType, StatType } from '@domain/value-objects/GaugeType';
|
||||
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { tierNum, tierRate } from '@domain/value-objects/Tier';
|
||||
import { computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive, calcLevel200EtaLive, elapsedLive } from '@presentation/helpers/gauge-live';
|
||||
import { esc, fmt, fmtClock } from '@presentation/helpers/format';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { UndoManager } from '@presentation/services/UndoManager';
|
||||
|
||||
interface StatPillDef {
|
||||
key: StatType;
|
||||
icon: string;
|
||||
color: string;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
const STAT_PILLS: StatPillDef[] = [
|
||||
{ key: 'serenite', icon: 'sentiment_satisfied_alt', color: '96,165,250', min: -5000, max: 5000 },
|
||||
{ key: 'endurance', icon: 'bolt', color: '250,204,21', min: 0, max: 20000 },
|
||||
{ key: 'maturite', icon: 'water_drop', color: '34,211,238', min: 0, max: 20000 },
|
||||
{ key: 'amour', icon: 'favorite', color: '248,113,113', min: 0, max: 20000 },
|
||||
{ key: 'xp', icon: 'star', color: '254,240,138', min: 1, max: 200 },
|
||||
];
|
||||
|
||||
export class DragodindeCard {
|
||||
private el: HTMLElement | null = null;
|
||||
private enclosId = 0;
|
||||
private ddId = 0;
|
||||
private lastTick = -1;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private onReorder?: () => void,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement, enclosId: number, ddId: number): void {
|
||||
this.enclosId = enclosId;
|
||||
this.ddId = ddId;
|
||||
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'dd-card enc-dd-card';
|
||||
this.el.id = `ddc-${enclosId}-${ddId}`;
|
||||
this.el.draggable = true;
|
||||
|
||||
this.el.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
e.dataTransfer?.setData('text/dd-id', String(ddId));
|
||||
e.dataTransfer?.setData('text/enc-id', String(enclosId));
|
||||
// Délai pour que le navigateur capture le snapshot avant d'appliquer l'opacité
|
||||
requestAnimationFrame(() => this.el!.classList.add('dragging'));
|
||||
});
|
||||
this.el.addEventListener('dragend', () => {
|
||||
this.el!.classList.remove('dragging');
|
||||
this.el!.classList.remove('drag-over');
|
||||
});
|
||||
this.el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
this.el!.classList.add('drag-over');
|
||||
});
|
||||
this.el.addEventListener('dragleave', (e) => {
|
||||
// Ignorer si on entre dans un élément enfant
|
||||
if (this.el!.contains(e.relatedTarget as Node)) return;
|
||||
this.el!.classList.remove('drag-over');
|
||||
});
|
||||
this.el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.el!.classList.remove('drag-over');
|
||||
const srcDdId = e.dataTransfer?.getData('text/dd-id');
|
||||
const srcEncId = e.dataTransfer?.getData('text/enc-id');
|
||||
if (srcEncId === String(enclosId) && srcDdId && srcDdId !== String(ddId)) {
|
||||
this.commandBus.execute({
|
||||
type: 'reorder-dragodinde',
|
||||
enclosId,
|
||||
fromDdId: Number(srcDdId),
|
||||
toDdId: ddId,
|
||||
});
|
||||
this.onReorder?.();
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(this.el);
|
||||
this.renderInner();
|
||||
}
|
||||
|
||||
private renderInner(): void {
|
||||
if (!this.el) return;
|
||||
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: this.enclosId });
|
||||
const dd = enc.dragodindes.find(d => d.id === this.ddId);
|
||||
if (!dd) return;
|
||||
|
||||
const eId = this.enclosId;
|
||||
const dId = this.ddId;
|
||||
|
||||
/* stat pills */
|
||||
const pillsHtml = STAT_PILLS.map(sp => {
|
||||
const val = dd.stats[sp.key];
|
||||
const atMax = val >= sp.max;
|
||||
const atMin = sp.key === 'serenite' && val <= sp.min;
|
||||
const atLimit = atMax || atMin;
|
||||
return `<div class="dd-stat-pill enc-dd-stat-badge${atLimit ? ' at-max' : ''}" style="border-color:rgba(${sp.color},0.5)${atLimit ? `;box-shadow:0 0 8px rgba(${sp.color},0.5);background:rgba(${sp.color},0.18)` : ''}">
|
||||
<span class="material-symbols-outlined enc-dd-stat-icon" style="color:rgb(${sp.color})">${sp.icon}</span>
|
||||
<input type="number" class="pill-input enc-dd-stat-input"
|
||||
id="pstat-${eId}-${dId}-${sp.key}"
|
||||
data-stat="${sp.key}" data-prev="${val}"
|
||||
min="${sp.min}" max="${sp.max}"
|
||||
value="${val}">
|
||||
<span class="pill-delta" id="pill-delta-${eId}-${dId}-${sp.key}"></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
/* Mapping icônes Material Symbols pour boutons jauges */
|
||||
const GAUGE_MS_ICONS: Partial<Record<string, string>> = {
|
||||
baffeur: 'remove', caresseur: 'add', foudroyeur: 'bolt',
|
||||
abreuvoir: 'water_drop', dragofesse: 'favorite',
|
||||
};
|
||||
|
||||
/* active gauge blocks */
|
||||
const gaugeBlocksHtml = enc.activeGauges.map(gid => {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
if (gid === 'mangeoire') {
|
||||
return `<div class="enc-dd-gauge-block enc-dd-gauge-xp" data-gid="${gid}">
|
||||
<div class="enc-dd-xp-main">
|
||||
<div class="enc-dd-xp-left">
|
||||
<span class="enc-dd-xp-niv" id="slv-${eId}-${dId}-${gid}" style="color:var(${def.cssVar})">NIV. 1</span>
|
||||
<span class="enc-dd-xp-sub">XP <span id="eta200-pct-${eId}-${dId}">0%</span></span>
|
||||
</div>
|
||||
<span class="live-cd enc-dd-xp-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
|
||||
</div>
|
||||
<span id="eta200-${eId}-${dId}" class="enc-dd-xp-eta200">→ NIV. 200 : —</span>
|
||||
<div class="enc-dd-bar-bg">
|
||||
<div class="enc-dd-bar-fill" id="eta200-bar-${eId}-${dId}" style="width:0%;background:var(${def.cssVar})"></div>
|
||||
</div>
|
||||
<div class="enc-dd-bar-bg" style="margin-top:4px">
|
||||
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
|
||||
</div>
|
||||
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
|
||||
</div>`;
|
||||
}
|
||||
const msIcon = GAUGE_MS_ICONS[gid] ?? 'circle';
|
||||
return `<div class="enc-dd-gauge-block" data-gid="${gid}">
|
||||
<div class="enc-dd-gauge-btn">
|
||||
<span class="enc-dd-gauge-btn-left">
|
||||
<span class="material-symbols-outlined" style="font-size:14px">${msIcon}</span>
|
||||
<span class="enc-dd-gauge-btn-name">${def.label.toUpperCase()}</span>
|
||||
</span>
|
||||
<span class="live-val" id="slv-${eId}-${dId}-${gid}" style="display:none"></span>
|
||||
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
|
||||
<span class="live-cd enc-dd-gauge-btn-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
|
||||
</div>
|
||||
<div class="enc-dd-bar-bg">
|
||||
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.el.innerHTML = `
|
||||
<div class="enc-dd-done-badge" id="dd-done-${eId}-${dId}" style="display:none">✓ TERMINÉ</div>
|
||||
<div class="enc-dd-card-head">
|
||||
<span class="dd-drag-handle enc-dd-drag-handle" title="Déplacer">⠿</span>
|
||||
<input type="text" class="dd-name enc-dd-name-input" value="${esc(dd.name)}"
|
||||
data-prev="${esc(dd.name)}" id="ddname-${eId}-${dId}">
|
||||
<button class="dd-del enc-dd-del-btn" title="Supprimer">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="enc-dd-body">
|
||||
<div class="enc-dd-stats-grid">${pillsHtml}</div>
|
||||
<div class="enc-dd-cibles">
|
||||
<div class="enc-dd-cible-row">
|
||||
<div class="enc-dd-cible-left">
|
||||
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(96,165,250)">sentiment_satisfied_alt</span>
|
||||
<span class="enc-dd-cible-lbl">Cible</span>
|
||||
</div>
|
||||
<div class="enc-dd-cible-right">
|
||||
<input type="number" class="enc-dd-cible-inp" id="ser-tgt-${eId}-${dId}"
|
||||
min="${enc.activeGauges.includes('baffeur') ? '-5000' : '0'}"
|
||||
max="${enc.activeGauges.includes('caresseur') ? '5000' : '0'}"
|
||||
value="${dd.sereniteTarget ?? ''}" placeholder="${enc.activeGauges.includes('baffeur') ? '-5000…0' : enc.activeGauges.includes('caresseur') ? '0…5000' : '—'}">
|
||||
<button class="enc-dd-cible-clr" id="ser-clr-${eId}-${dId}" title="Réinitialiser" ${dd.sereniteTarget == null ? 'style="visibility:hidden"' : ''}>✕</button>
|
||||
<span class="enc-dd-cible-eta" id="ser-eta-${eId}-${dId}">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enc-dd-cible-row">
|
||||
<div class="enc-dd-cible-left">
|
||||
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(234,179,8)">stars</span>
|
||||
<span class="enc-dd-cible-lbl">Niveau</span>
|
||||
</div>
|
||||
<div class="enc-dd-cible-right">
|
||||
<input type="number" class="enc-dd-cible-inp" id="lvl-tgt-${eId}-${dId}"
|
||||
min="1" max="200"
|
||||
value="${dd.levelTarget ?? ''}" placeholder="—">
|
||||
<button class="enc-dd-cible-clr" id="lvl-clr-${eId}-${dId}" title="Réinitialiser" ${dd.levelTarget == null ? 'style="visibility:hidden"' : ''}>✕</button>
|
||||
<span class="enc-dd-cible-eta" id="lvl-eta-${eId}-${dId}">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enc-dd-gauge-blocks">${gaugeBlocksHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents(dd);
|
||||
}
|
||||
|
||||
private bindEvents(dd: Dragodinde): void {
|
||||
if (!this.el) return;
|
||||
const eId = this.enclosId;
|
||||
const dId = this.ddId;
|
||||
|
||||
/* Delete button */
|
||||
const delBtn = this.el.querySelector('.dd-del');
|
||||
delBtn?.addEventListener('click', async () => {
|
||||
const ok = await ConfirmModal.show('Retirer la dragodinde', 'Retirer cette dragodinde de l\'enclos ?');
|
||||
if (!ok) return;
|
||||
const hasSnap = await UndoManager.snapshotCurrent('Dragodinde retirée');
|
||||
this.commandBus.execute({ type: 'remove-dragodinde', enclosId: eId, ddId: dId });
|
||||
Toast.show('success', 'Dragodinde retirée.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
|
||||
});
|
||||
|
||||
/* Name input */
|
||||
const nameInput = this.el.querySelector<HTMLInputElement>('.dd-name');
|
||||
if (nameInput) {
|
||||
nameInput.addEventListener('focus', () => {
|
||||
nameInput.dataset.prev = nameInput.value;
|
||||
nameInput.value = '';
|
||||
});
|
||||
nameInput.addEventListener('blur', () => {
|
||||
const v = nameInput.value.trim();
|
||||
if (!v) nameInput.value = nameInput.dataset.prev || dd.name;
|
||||
else this.commandBus.execute({ type: 'rename-dragodinde', enclosId: eId, ddId: dId, name: v });
|
||||
});
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || dd.name; nameInput.blur(); }
|
||||
else if (e.key === 'Enter') nameInput.blur();
|
||||
});
|
||||
}
|
||||
|
||||
/* Stat pill inputs */
|
||||
this.el.querySelectorAll<HTMLInputElement>('.pill-input').forEach(inp => {
|
||||
const stat = inp.dataset.stat as StatType;
|
||||
inp.addEventListener('focus', () => {
|
||||
inp.dataset.prev = inp.value;
|
||||
inp.value = '';
|
||||
});
|
||||
inp.addEventListener('input', () => {
|
||||
if (!inp.value) return;
|
||||
const v = Number(inp.value);
|
||||
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
|
||||
});
|
||||
inp.addEventListener('blur', () => {
|
||||
if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; }
|
||||
const v = Number(inp.value);
|
||||
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
|
||||
});
|
||||
inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); }
|
||||
else if (e.key === 'Enter') inp.blur();
|
||||
});
|
||||
});
|
||||
|
||||
/* Clear buttons */
|
||||
this.el.querySelector(`#ser-clr-${eId}-${dId}`)?.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: null });
|
||||
const inp = this.el?.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
|
||||
if (inp) inp.value = '';
|
||||
const btn = this.el?.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
|
||||
if (btn) btn.style.visibility = 'hidden';
|
||||
});
|
||||
this.el.querySelector(`#lvl-clr-${eId}-${dId}`)?.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: null });
|
||||
const inp = this.el?.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
|
||||
if (inp) inp.value = '';
|
||||
const btn = this.el?.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
|
||||
if (btn) btn.style.visibility = 'hidden';
|
||||
});
|
||||
|
||||
/* Serenite target — clamp selon la jauge active (baffeur → négatif, caresseur → positif) */
|
||||
const serTgt = this.el.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
|
||||
if (serTgt) {
|
||||
const clampSeren = (raw: number): number => {
|
||||
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: eId });
|
||||
if (enc.activeGauges.includes('baffeur')) return Math.min(0, Math.max(-5000, raw));
|
||||
if (enc.activeGauges.includes('caresseur')) return Math.max(0, Math.min(5000, raw));
|
||||
return Math.min(5000, Math.max(-5000, raw));
|
||||
};
|
||||
serTgt.addEventListener('focus', () => { serTgt.dataset.prev = serTgt.value; serTgt.value = ''; });
|
||||
serTgt.addEventListener('input', () => {
|
||||
if (!serTgt.value) return;
|
||||
const v = Number(serTgt.value);
|
||||
if (isNaN(v)) return;
|
||||
const clamped = clampSeren(v);
|
||||
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
|
||||
});
|
||||
serTgt.addEventListener('blur', () => {
|
||||
if (serTgt.value === '') { serTgt.value = serTgt.dataset.prev || ''; return; }
|
||||
const v = Number(serTgt.value);
|
||||
if (isNaN(v)) { serTgt.value = serTgt.dataset.prev || ''; return; }
|
||||
const clamped = clampSeren(v);
|
||||
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
|
||||
serTgt.value = String(clamped);
|
||||
});
|
||||
serTgt.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { serTgt.value = serTgt.dataset.prev || ''; serTgt.blur(); }
|
||||
else if (e.key === 'Enter') serTgt.blur();
|
||||
});
|
||||
}
|
||||
|
||||
/* Level target */
|
||||
const lvlTgt = this.el.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
|
||||
if (lvlTgt) {
|
||||
lvlTgt.addEventListener('focus', () => { lvlTgt.dataset.prev = lvlTgt.value; lvlTgt.value = ''; });
|
||||
lvlTgt.addEventListener('input', () => {
|
||||
if (!lvlTgt.value) return;
|
||||
const v = Number(lvlTgt.value);
|
||||
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
|
||||
});
|
||||
lvlTgt.addEventListener('blur', () => {
|
||||
if (lvlTgt.value === '') { lvlTgt.value = lvlTgt.dataset.prev || ''; return; }
|
||||
const v = Number(lvlTgt.value);
|
||||
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
|
||||
});
|
||||
lvlTgt.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { lvlTgt.value = lvlTgt.dataset.prev || ''; lvlTgt.blur(); }
|
||||
else if (e.key === 'Enter') lvlTgt.blur();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
update(enc: Enclos, dd: Dragodinde, el: number, started: boolean): void {
|
||||
if (!this.el) return;
|
||||
const eId = this.enclosId;
|
||||
const dId = this.ddId;
|
||||
|
||||
/* Détection de tick (toutes les 10 sec).
|
||||
* Après complétion automatique, le temps réel continue pour que les animations
|
||||
* continuent sur toutes les jauges qui se vident en fond. */
|
||||
const elForTick = started ? elapsedLive(enc) : 0;
|
||||
const tick = started ? Math.floor(elForTick / 10) : -1;
|
||||
if (!started) {
|
||||
this.lastTick = -1;
|
||||
} else if (this.lastTick === -1) {
|
||||
this.lastTick = tick; // initialise sans déclencher au démarrage
|
||||
}
|
||||
const newTick = started && tick !== this.lastTick;
|
||||
if (newTick) this.lastTick = tick;
|
||||
|
||||
let allDone = enc.activeGauges.length > 0;
|
||||
|
||||
/* Update active gauge blocks */
|
||||
enc.activeGauges.forEach(gid => {
|
||||
const r = computeGaugeLive(enc, dd, gid, el, started);
|
||||
const def = GAUGE_DEFS[gid];
|
||||
|
||||
// Toutes les jauges comptent pour le badge "✓ TERMINÉ"
|
||||
if (!r.done) allDone = false;
|
||||
|
||||
const lvEl = this.el!.querySelector(`#slv-${eId}-${dId}-${gid}`);
|
||||
if (lvEl) lvEl.textContent = r.liveText;
|
||||
|
||||
/* Delta live-delta : pop à chaque tick.
|
||||
* Continue jusqu'au cap absolu de la stat (pas juste la cible). */
|
||||
const sd = def.isXp ? null : STAT_DEFS[def.stat as keyof typeof STAT_DEFS];
|
||||
const atAbsCap = def.isXp
|
||||
? (r.estStat as number) >= 200
|
||||
: (def.dir > 0 ? (r.estStat as number) >= sd!.max : (r.estStat as number) <= sd!.min);
|
||||
const deltaActive = started && !atAbsCap;
|
||||
const deltaEl = this.el!.querySelector<HTMLElement>(`#sdelta-${eId}-${dId}-${gid}`);
|
||||
if (deltaEl) {
|
||||
deltaEl.textContent = r.deltaText;
|
||||
if (newTick && deltaActive) {
|
||||
deltaEl.classList.remove('show');
|
||||
void deltaEl.offsetWidth; // force reflow pour relancer l'animation
|
||||
deltaEl.classList.add('show');
|
||||
} else if (!deltaActive) {
|
||||
deltaEl.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
const cdEl = this.el!.querySelector(`#scd-${eId}-${dId}-${gid}`);
|
||||
if (cdEl) cdEl.textContent = r.done ? '✅' : (!isFinite(r.cntDown) ? '∞' : fmtClock(r.cntDown));
|
||||
|
||||
/* ETA + barre de progression niveau 200 (mangeoire uniquement) */
|
||||
if (gid === 'mangeoire') {
|
||||
const eta200El = this.el!.querySelector(`#eta200-${eId}-${dId}`);
|
||||
if (eta200El) {
|
||||
const eta = calcLevel200EtaLive(enc, dd, el, started);
|
||||
eta200El.textContent = `→ NIV. 200 : ${eta || '—'}`;
|
||||
}
|
||||
const pct200 = Math.min(100, Math.max(0, ((r.estStat as number) - 1) / 199 * 100));
|
||||
const barFillEl = this.el!.querySelector<HTMLElement>(`#eta200-bar-${eId}-${dId}`);
|
||||
if (barFillEl) barFillEl.style.width = `${pct200.toFixed(1)}%`;
|
||||
const pctEl = this.el!.querySelector<HTMLElement>(`#eta200-pct-${eId}-${dId}`);
|
||||
if (pctEl) pctEl.textContent = `${Math.round(pct200)}%`;
|
||||
}
|
||||
|
||||
const pbEl = this.el!.querySelector<HTMLElement>(`#spb-${eId}-${dId}-${gid}`);
|
||||
if (pbEl) pbEl.style.width = `${r.progPct.toFixed(1)}%`;
|
||||
|
||||
/* Mise à jour live du badge de stat correspondant.
|
||||
* Désactivée après complétion (__done__) : dd.stats est déjà à jour
|
||||
* et l'utilisateur doit pouvoir corriger les valeurs à la main. */
|
||||
if (started && !enc.alerted['__done__']) {
|
||||
const pillInput = this.el!.querySelector<HTMLInputElement>(`#pstat-${eId}-${dId}-${def.stat}`);
|
||||
if (pillInput && document.activeElement !== pillInput) {
|
||||
pillInput.value = String(Math.round(r.estStat as number));
|
||||
}
|
||||
const sp = STAT_PILLS.find(p => p.key === def.stat);
|
||||
if (sp) {
|
||||
const val = Math.round(r.estStat as number);
|
||||
const atLimit = val >= sp.max || (sp.key === 'serenite' && val <= sp.min);
|
||||
const pill = pillInput?.closest<HTMLElement>('.dd-stat-pill');
|
||||
if (pill) {
|
||||
pill.classList.toggle('at-max', atLimit);
|
||||
pill.style.background = atLimit ? `rgba(${sp.color},0.18)` : '';
|
||||
pill.style.boxShadow = atLimit ? `0 0 8px rgba(${sp.color},0.5)` : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Pill delta : pop à chaque tick — uniquement endurance, maturite, amour */
|
||||
const pillDeltaStats = ['endurance', 'maturite', 'amour'];
|
||||
if (pillDeltaStats.includes(def.stat)) {
|
||||
const pillDelta = this.el!.querySelector<HTMLElement>(`#pill-delta-${eId}-${dId}-${def.stat}`);
|
||||
if (pillDelta) {
|
||||
pillDelta.textContent = r.deltaText;
|
||||
if (newTick && started && !r.done) {
|
||||
pillDelta.classList.remove('show');
|
||||
void pillDelta.offsetWidth;
|
||||
pillDelta.classList.add('show');
|
||||
} else if (!started || r.done) {
|
||||
pillDelta.classList.remove('show');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* Done badge */
|
||||
const doneBadge = this.el.querySelector<HTMLElement>(`#dd-done-${eId}-${dId}`);
|
||||
if (doneBadge) {
|
||||
doneBadge.style.display = (allDone && started && enc.activeGauges.length > 0) ? '' : 'none';
|
||||
}
|
||||
|
||||
/* Serenity ETA */
|
||||
const serEta = this.el.querySelector(`#ser-eta-${eId}-${dId}`);
|
||||
if (serEta) serEta.innerHTML = calcSerenEtaLive(enc, dd, el, started);
|
||||
const serClr = this.el.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
|
||||
if (serClr) serClr.style.visibility = dd.sereniteTarget == null ? 'hidden' : 'visible';
|
||||
|
||||
/* Level ETA */
|
||||
const lvlEta = this.el.querySelector(`#lvl-eta-${eId}-${dId}`);
|
||||
if (lvlEta) lvlEta.innerHTML = calcLevelEtaLive(enc, dd, el, started);
|
||||
const lvlClr = this.el.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
|
||||
if (lvlClr) lvlClr.style.visibility = dd.levelTarget == null ? 'hidden' : 'visible';
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
474
src/presentation/components/EnclosView.ts
Executable file
474
src/presentation/components/EnclosView.ts
Executable file
@ -0,0 +1,474 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { UIState } from '@presentation/state/UIState';
|
||||
import type { Enclos } from '@domain/entities/Enclos';
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { tierNum, tierRate } from '@domain/value-objects/Tier';
|
||||
import { elapsed, timeToGain } from '@domain/services/GaugeCalculator';
|
||||
import { enclosGlobalState, enclosGaugeCurGl, computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive } from '@presentation/helpers/gauge-live';
|
||||
import { esc, fmt, fmtClock } from '@presentation/helpers/format';
|
||||
import { DragodindeCard } from './DragodindeCard';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { UndoManager } from '@presentation/services/UndoManager';
|
||||
|
||||
const ALL_GAUGES: GaugeType[] = ['baffeur', 'caresseur', 'foudroyeur', 'abreuvoir', 'dragofesse', 'mangeoire'];
|
||||
|
||||
interface CachedGaugeEls {
|
||||
tier: Element | null;
|
||||
bar: HTMLElement | null;
|
||||
empty: Element | null;
|
||||
inp: HTMLInputElement | null;
|
||||
}
|
||||
|
||||
export class EnclosView {
|
||||
private el: HTMLElement | null = null;
|
||||
private enclosId = 0;
|
||||
private ddCards: Map<number, DragodindeCard> = new Map();
|
||||
private cachedEls: {
|
||||
elapsed: Element | null;
|
||||
gcd: Element | null;
|
||||
tbtn: HTMLButtonElement | null;
|
||||
resetBtn: HTMLElement | null;
|
||||
doneBanner: HTMLElement | null;
|
||||
doneResetBtn: HTMLElement | null;
|
||||
ddCount: Element | null;
|
||||
addBtn: HTMLButtonElement | null;
|
||||
gauges: Map<string, CachedGaugeEls>;
|
||||
} | null = null;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private uiState: UIState,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement, enclosId: number): void {
|
||||
this.enclosId = enclosId;
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'enclos-view';
|
||||
container.appendChild(this.el);
|
||||
this.renderInner();
|
||||
}
|
||||
|
||||
private getEnc(): Enclos {
|
||||
return this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: this.enclosId });
|
||||
}
|
||||
|
||||
private renderInner(): void {
|
||||
if (!this.el) return;
|
||||
const enc = this.getEnc();
|
||||
const eId = this.enclosId;
|
||||
const started = !!enc.timer.startTime;
|
||||
const running = enc.timer.running;
|
||||
|
||||
/* Gauge toggle buttons */
|
||||
const SEREN_PAIR: Record<string, GaugeType> = { baffeur: 'caresseur', caresseur: 'baffeur' };
|
||||
const gaugeBtnsHtml = ALL_GAUGES.map(gid => {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
const active = enc.activeGauges.includes(gid);
|
||||
const locked = started && !enc.alerted['__done__'];
|
||||
// Exclusion mutuelle : si la jauge opposée (baffeur↔caresseur) est active, on bloque
|
||||
const opposite = SEREN_PAIR[gid];
|
||||
const blocked = !active && !!opposite && enc.activeGauges.includes(opposite);
|
||||
const cls = `gauge-btn enc-gauge-toggle${active ? ' on' : ''}${locked || blocked ? ' locked' : ''}`;
|
||||
return `<button class="${cls}" data-gid="${gid}" style="${active ? `border-color:var(${def.cssVar})` : ''}"
|
||||
${locked || blocked ? 'disabled' : ''}>${def.icon} ${def.label}</button>`;
|
||||
}).join('');
|
||||
|
||||
/* Active gauge config blocks */
|
||||
const gaugeConfigsHtml = enc.activeGauges.map(gid => {
|
||||
const def = GAUGE_DEFS[gid];
|
||||
const lvl = enc.gaugeLevels[gid] || 0;
|
||||
const tn = tierNum(lvl);
|
||||
const tr = tierRate(lvl);
|
||||
const pct = Math.min(100, (lvl / 100000) * 100);
|
||||
const emptyTime = timeToGain(lvl, lvl);
|
||||
const emptyStr = emptyTime === Infinity ? '∞' : fmt(emptyTime);
|
||||
return `<div class="enc-gauge-card" data-gid="${gid}" id="gcfg-${eId}-${gid}">
|
||||
<div class="enc-gauge-card-head">
|
||||
<div class="enc-gauge-card-name" style="color:var(${def.cssVar})">${def.icon} ${def.label.toUpperCase()}</div>
|
||||
<span class="enc-tier-badge" id="gtier-${eId}-${gid}"
|
||||
style="color:var(${def.cssVar});border-color:color-mix(in srgb,var(${def.cssVar}) 40%,transparent);background:color-mix(in srgb,var(${def.cssVar}) 15%,transparent)">Tier ${tn} · ±${tr}/tick</span>
|
||||
</div>
|
||||
<div class="enc-gauge-bar-bg">
|
||||
<div class="enc-gauge-bar-inner">
|
||||
<div class="enc-gauge-bar-fill" id="gbar-${eId}-${gid}" style="width:${pct.toFixed(1)}%;background:linear-gradient(to right,color-mix(in srgb,var(${def.cssVar}) 70%,#000),var(${def.cssVar}))"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enc-gauge-bottom">
|
||||
<div class="enc-gauge-val-group">
|
||||
<input type="number" class="gauge-inp enc-gauge-inp${started ? ' gauge-inp-recharge recharge' : ''}" id="glvl-${eId}-${gid}"
|
||||
data-gid="${gid}" data-prev="${lvl}" data-running="${started ? '1' : '0'}"
|
||||
min="0" max="100000" step="1000" value="${lvl}"
|
||||
title="${started ? 'Recharger la jauge en cours de session' : ''}">
|
||||
<span class="enc-gauge-inp-max">/ 100 000</span>
|
||||
</div>
|
||||
<div class="enc-gauge-info" id="gempty-${eId}-${gid}">Vide en ${emptyStr}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
/* Timer button */
|
||||
const timerIcon = running ? 'pause' : 'play_arrow';
|
||||
const timerBtnClass = running ? 'enc-start-btn enc-btn-pause' : 'enc-start-btn';
|
||||
const timerBtnText = running ? 'PAUSE' : (enc.timer.pausedAt && !enc.alerted['__done__'] ? 'REPRENDRE' : 'DÉMARRER');
|
||||
|
||||
/* DD count */
|
||||
const ddCount = enc.dragodindes.length;
|
||||
const ddMax = 10;
|
||||
|
||||
this.el.innerHTML = `
|
||||
<div class="enc-view-inner">
|
||||
|
||||
<!-- Panel glassmorphism principal -->
|
||||
<div class="enc-panel">
|
||||
|
||||
<!-- Ligne principale : [nom + vider] ←→ [elapsed | alarme | bouton] -->
|
||||
<div class="enc-header-row">
|
||||
<!-- Gauche : nom + vider -->
|
||||
<div class="enc-header-left">
|
||||
<input type="text" class="enc-name-input" value="${esc(enc.name)}"
|
||||
size="${Math.max(4, enc.name.length + 1)}"
|
||||
data-prev="${esc(enc.name)}" id="ename-${eId}">
|
||||
<button class="enc-clear-btn" id="eclear-${eId}">
|
||||
<span class="material-symbols-outlined" style="font-size:13px">delete</span>
|
||||
Vider l'enclos
|
||||
</button>
|
||||
</div>
|
||||
<!-- Droite : temps écoulé + alarme + démarrer -->
|
||||
<div class="enc-header-right">
|
||||
<div class="enc-time-block">
|
||||
<div class="enc-time-lbl">Temps Écoulé</div>
|
||||
<div class="enc-elapsed" id="elapsed-${eId}">00:00:00</div>
|
||||
</div>
|
||||
<div class="enc-time-block">
|
||||
<div class="enc-time-lbl enc-alarm-lbl">
|
||||
<span class="material-symbols-outlined mso-fill" style="font-size:12px;color:#00ff00">notifications</span>
|
||||
Alarme dans
|
||||
</div>
|
||||
<div class="enc-alarm" id="gcd-${eId}">--:--:--</div>
|
||||
</div>
|
||||
<button class="${timerBtnClass}" id="tbtn-${eId}">
|
||||
<span class="material-symbols-outlined">${timerIcon}</span>${timerBtnText}
|
||||
</button>
|
||||
<button class="enc-reset-btn" id="treset-${eId}" ${!started ? 'style="display:none"' : ''} title="Réinitialiser le timer">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jauges actives -->
|
||||
<div class="enc-gauge-label">Jauges Actives :</div>
|
||||
<div class="enc-gauge-toggles" id="gtoggle-${eId}">${gaugeBtnsHtml}</div>
|
||||
<div class="enc-gauges-grid" id="gconfigs-${eId}">${gaugeConfigsHtml}</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bandeau session terminée -->
|
||||
<div class="enc-done-banner" id="done-banner-${eId}" style="display:none">
|
||||
<span class="material-symbols-outlined mso-fill" style="color:#22c55e;font-size:22px">check_circle</span>
|
||||
<div class="enc-done-texts">
|
||||
<div class="enc-done-title">Session terminée !</div>
|
||||
<div class="enc-done-sub">Toutes les cibles ont été atteintes</div>
|
||||
</div>
|
||||
<button class="enc-done-reset-btn" id="done-reset-${eId}" style="display:none">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
|
||||
Nouvelle fournée
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Section Dragodindes -->
|
||||
<div class="enc-dd-section">
|
||||
<div class="enc-dd-head">
|
||||
<h3 class="enc-dd-title">
|
||||
Dragodindes <span class="enc-dd-count" id="ddcount-${eId}">${ddCount}/${ddMax}</span>
|
||||
</h3>
|
||||
<button class="enc-add-dd-btn" id="adddd-${eId}" ${ddCount >= ddMax ? 'disabled' : ''}>
|
||||
<span class="material-symbols-outlined" style="font-size:16px">add</span>
|
||||
Ajouter une Dragodinde
|
||||
</button>
|
||||
</div>
|
||||
<div class="dd-grid" id="dd-grid-${eId}"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.bindEvents(enc);
|
||||
this.cacheElements(enc);
|
||||
this.renderDdCards(enc);
|
||||
}
|
||||
|
||||
private cacheElements(enc: Enclos): void {
|
||||
if (!this.el) return;
|
||||
const eId = this.enclosId;
|
||||
const gauges = new Map<string, CachedGaugeEls>();
|
||||
enc.activeGauges.forEach(gid => {
|
||||
gauges.set(gid, {
|
||||
tier: this.el!.querySelector(`#gtier-${eId}-${gid}`),
|
||||
bar: this.el!.querySelector<HTMLElement>(`#gbar-${eId}-${gid}`),
|
||||
empty: this.el!.querySelector(`#gempty-${eId}-${gid}`),
|
||||
inp: this.el!.querySelector<HTMLInputElement>(`#glvl-${eId}-${gid}`),
|
||||
});
|
||||
});
|
||||
this.cachedEls = {
|
||||
elapsed: this.el.querySelector(`#elapsed-${eId}`),
|
||||
gcd: this.el.querySelector(`#gcd-${eId}`),
|
||||
tbtn: this.el.querySelector<HTMLButtonElement>(`#tbtn-${eId}`),
|
||||
resetBtn: this.el.querySelector<HTMLElement>(`#treset-${eId}`),
|
||||
doneBanner: this.el.querySelector<HTMLElement>(`#done-banner-${eId}`),
|
||||
doneResetBtn: this.el.querySelector<HTMLElement>(`#done-reset-${eId}`),
|
||||
ddCount: this.el.querySelector(`#ddcount-${eId}`),
|
||||
addBtn: this.el.querySelector<HTMLButtonElement>(`#adddd-${eId}`),
|
||||
gauges,
|
||||
};
|
||||
}
|
||||
|
||||
private bindEvents(enc: Enclos): void {
|
||||
if (!this.el) return;
|
||||
const eId = this.enclosId;
|
||||
|
||||
/* Enclos name input */
|
||||
const nameInput = this.el.querySelector<HTMLInputElement>(`#ename-${eId}`);
|
||||
if (nameInput) {
|
||||
nameInput.addEventListener('focus', () => {
|
||||
nameInput.dataset.prev = nameInput.value;
|
||||
nameInput.value = '';
|
||||
});
|
||||
nameInput.addEventListener('blur', () => {
|
||||
const v = nameInput.value.trim();
|
||||
if (!v) nameInput.value = nameInput.dataset.prev || enc.name;
|
||||
else this.commandBus.execute({ type: 'rename-enclos', enclosId: eId, name: v });
|
||||
});
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || enc.name; nameInput.blur(); }
|
||||
else if (e.key === 'Enter') nameInput.blur();
|
||||
});
|
||||
}
|
||||
|
||||
/* Clear enclos */
|
||||
this.el.querySelector(`#eclear-${eId}`)?.addEventListener('click', async () => {
|
||||
const ok = await ConfirmModal.show('Vider l\'enclos', 'Toutes les dragodindes seront supprimées. Continuer ?');
|
||||
if (!ok) return;
|
||||
const hasSnap = await UndoManager.snapshotCurrent('Enclos vidé');
|
||||
this.commandBus.execute({ type: 'clear-enclos', enclosId: eId });
|
||||
Toast.show('success', 'Enclos vidé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
|
||||
this.renderInner();
|
||||
});
|
||||
|
||||
/* Gauge toggles */
|
||||
this.el.querySelectorAll<HTMLButtonElement>('.gauge-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const gid = btn.dataset.gid as GaugeType;
|
||||
this.commandBus.execute({ type: 'toggle-gauge', enclosId: eId, gaugeId: gid });
|
||||
this.renderInner();
|
||||
});
|
||||
});
|
||||
|
||||
/* Gauge level inputs */
|
||||
this.el.querySelectorAll<HTMLInputElement>('.gauge-inp').forEach(inp => {
|
||||
inp.addEventListener('focus', () => {
|
||||
inp.dataset.prev = inp.value;
|
||||
inp.value = '';
|
||||
});
|
||||
inp.addEventListener('blur', () => {
|
||||
if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; }
|
||||
const v = Math.min(100000, Math.max(0, Number(inp.value)));
|
||||
if (isNaN(v)) { inp.value = inp.dataset.prev || '0'; return; }
|
||||
const gid = inp.dataset.gid as GaugeType;
|
||||
if (inp.dataset.running === '1') {
|
||||
this.commandBus.execute({ type: 'recharge-gauge', enclosId: eId, gaugeId: gid, level: v });
|
||||
} else {
|
||||
this.commandBus.execute({ type: 'update-gauge-level', enclosId: eId, gaugeId: gid, level: v });
|
||||
}
|
||||
inp.value = String(v);
|
||||
});
|
||||
inp.addEventListener('input', () => {
|
||||
if (!inp.value) return;
|
||||
const v = Math.min(100000, Math.max(0, Number(inp.value)));
|
||||
if (isNaN(v)) return;
|
||||
const gid = inp.dataset.gid as GaugeType;
|
||||
if (inp.dataset.running === '1') {
|
||||
// Recharge en temps réel pendant la session (consolidé côté command)
|
||||
this.commandBus.execute({ type: 'recharge-gauge', enclosId: eId, gaugeId: gid, level: v });
|
||||
} else {
|
||||
this.commandBus.execute({ type: 'update-gauge-level', enclosId: eId, gaugeId: gid, level: v });
|
||||
}
|
||||
});
|
||||
inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); }
|
||||
else if (e.key === 'Enter') inp.blur();
|
||||
});
|
||||
});
|
||||
|
||||
/* Timer start/pause */
|
||||
this.el.querySelector(`#tbtn-${eId}`)?.addEventListener('click', () => {
|
||||
const freshEnc = this.getEnc();
|
||||
if (freshEnc.timer.running) {
|
||||
this.commandBus.execute({ type: 'stop-timer', enclosId: eId });
|
||||
} else {
|
||||
this.commandBus.execute({ type: 'start-timer', enclosId: eId });
|
||||
}
|
||||
this.renderInner();
|
||||
});
|
||||
|
||||
/* Reset timer */
|
||||
this.el.querySelector(`#treset-${eId}`)?.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'reset-timer', enclosId: eId });
|
||||
this.renderInner();
|
||||
});
|
||||
|
||||
/* Done banner — Nouvelle fournée */
|
||||
this.el.querySelector(`#done-reset-${eId}`)?.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'nouvelle-fournee', enclosId: eId });
|
||||
Toast.show('success', 'Nouvelle fournée lancée.');
|
||||
this.renderInner();
|
||||
});
|
||||
|
||||
/* Add DD */
|
||||
this.el.querySelector(`#adddd-${eId}`)?.addEventListener('click', () => {
|
||||
this.commandBus.execute({ type: 'add-dragodinde', enclosId: eId });
|
||||
this.renderInner();
|
||||
});
|
||||
}
|
||||
|
||||
private renderDdCards(enc: Enclos): void {
|
||||
const grid = this.el?.querySelector(`#dd-grid-${this.enclosId}`);
|
||||
if (!grid) return;
|
||||
|
||||
/* Destroy old cards */
|
||||
this.ddCards.forEach(card => card.destroy());
|
||||
this.ddCards.clear();
|
||||
|
||||
/* Create new cards */
|
||||
enc.dragodindes.forEach(dd => {
|
||||
const card = new DragodindeCard(this.commandBus, this.queryBus, () => this.renderInner());
|
||||
card.render(grid as HTMLElement, this.enclosId, dd.id);
|
||||
this.ddCards.set(dd.id, card);
|
||||
});
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.el || !this.cachedEls) return;
|
||||
const enc = this.getEnc();
|
||||
const c = this.cachedEls;
|
||||
const { globalMax, allDone, started, el: elSec } = enclosGlobalState(enc);
|
||||
const running = enc.timer.running;
|
||||
|
||||
/* Complétion automatique : toutes les cibles atteintes → une seule alarme */
|
||||
if (allDone && running && enc.dragodindes.length > 0 && enc.activeGauges.length > 0) {
|
||||
this.commandBus.execute({ type: 'complete-timer', enclosId: this.enclosId });
|
||||
this.renderInner();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Elapsed clock */
|
||||
if (c.elapsed) c.elapsed.textContent = fmtClock(elSec);
|
||||
|
||||
/* Global countdown */
|
||||
if (c.gcd) {
|
||||
if (enc.activeGauges.length > 0 && enc.dragodindes.length > 0) {
|
||||
if (allDone) {
|
||||
c.gcd.textContent = '✅';
|
||||
} else if (!isFinite(globalMax)) {
|
||||
c.gcd.textContent = '∞';
|
||||
} else {
|
||||
c.gcd.textContent = fmtClock(globalMax);
|
||||
}
|
||||
} else {
|
||||
c.gcd.textContent = '--:--:--';
|
||||
}
|
||||
}
|
||||
|
||||
/* Timer button state */
|
||||
if (c.tbtn) {
|
||||
const timerIcon = running ? 'pause' : 'play_arrow';
|
||||
const timerText = running ? 'PAUSE' : (enc.timer.pausedAt && !enc.alerted['__done__'] ? 'REPRENDRE' : 'DÉMARRER');
|
||||
c.tbtn.className = running ? 'enc-start-btn enc-btn-pause' : 'enc-start-btn';
|
||||
c.tbtn.innerHTML = `<span class="material-symbols-outlined">${timerIcon}</span>${timerText}`;
|
||||
}
|
||||
|
||||
/* Reset button visibility */
|
||||
if (c.resetBtn) c.resetBtn.style.display = started ? '' : 'none';
|
||||
|
||||
/* Gauge config live updates (cached — no querySelector per frame) */
|
||||
enc.activeGauges.forEach(gid => {
|
||||
const g = c.gauges.get(gid);
|
||||
if (!g) return;
|
||||
const startGl = started ? (enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid]) : enc.gaugeLevels[gid];
|
||||
const curGl = started ? enclosGaugeCurGl(enc, gid) : startGl;
|
||||
const tn = tierNum(curGl);
|
||||
const tr = curGl > 0 ? tierRate(curGl) : 0;
|
||||
const pct = Math.min(100, (curGl / 100000) * 100);
|
||||
const emptyTime = curGl > 0 ? timeToGain(curGl, curGl) : 0;
|
||||
const emptyStr = emptyTime === Infinity ? '∞' : fmt(emptyTime);
|
||||
|
||||
if (g.tier) {
|
||||
g.tier.textContent = curGl > 0 ? `Tier ${tn} · ±${tr}/tick` : 'Jauge vide';
|
||||
}
|
||||
if (g.bar) g.bar.style.width = `${pct.toFixed(1)}%`;
|
||||
if (g.empty) {
|
||||
if (curGl > 0) {
|
||||
g.empty.textContent = `Vide en ${emptyStr}`;
|
||||
g.empty.classList.remove('enc-gauge-alert');
|
||||
} else if (started && running) {
|
||||
g.empty.textContent = '⚠ Rechargez la jauge';
|
||||
g.empty.classList.add('enc-gauge-alert');
|
||||
} else {
|
||||
g.empty.textContent = 'Vide';
|
||||
g.empty.classList.remove('enc-gauge-alert');
|
||||
}
|
||||
}
|
||||
if (g.inp) {
|
||||
g.inp.dataset.running = started ? '1' : '0';
|
||||
if (document.activeElement !== g.inp) {
|
||||
g.inp.value = String(Math.round(started ? curGl : (enc.gaugeLevels[gid] ?? 0)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* Done banner */
|
||||
if (c.doneBanner) {
|
||||
c.doneBanner.style.display = (allDone && started && enc.dragodindes.length > 0 && enc.activeGauges.length > 0) ? '' : 'none';
|
||||
}
|
||||
|
||||
/* Bouton "Nouvelle fournée" */
|
||||
if (c.doneResetBtn) {
|
||||
const allMaxed = enc.dragodindes.length > 0 && enc.dragodindes.every(dd =>
|
||||
dd.stats.maturite >= 20000 && dd.stats.endurance >= 20000 && dd.stats.amour >= 20000
|
||||
);
|
||||
c.doneResetBtn.style.display = allMaxed ? '' : 'none';
|
||||
}
|
||||
|
||||
/* DD count */
|
||||
if (c.ddCount) c.ddCount.textContent = `${enc.dragodindes.length}/10`;
|
||||
|
||||
/* Add button state */
|
||||
if (c.addBtn) c.addBtn.disabled = enc.dragodindes.length >= 10;
|
||||
|
||||
/* Update each DD card */
|
||||
enc.dragodindes.forEach(dd => {
|
||||
const card = this.ddCards.get(dd.id);
|
||||
if (card) card.update(enc, dd, elSec, started);
|
||||
});
|
||||
|
||||
/* Clean up cards for removed DDs */
|
||||
const currentDdIds = new Set(enc.dragodindes.map(d => d.id));
|
||||
this.ddCards.forEach((card, id) => {
|
||||
if (!currentDdIds.has(id)) {
|
||||
card.destroy();
|
||||
this.ddCards.delete(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.ddCards.forEach(card => card.destroy());
|
||||
this.ddCards.clear();
|
||||
this.cachedEls = null;
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
48
src/presentation/components/GaugePill.ts
Executable file
48
src/presentation/components/GaugePill.ts
Executable file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GaugePill — minimal wrapper for gauge configuration display in EnclosView.
|
||||
* Renders a gauge level bar with tier badge and "vide en" info.
|
||||
*/
|
||||
|
||||
import type { GaugeType } from '@domain/value-objects/GaugeType';
|
||||
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
|
||||
import { tierNum, tierRate } from '@domain/value-objects/Tier';
|
||||
import { timeToGain } from '@domain/services/GaugeCalculator';
|
||||
import { fmt } from '@presentation/helpers/format';
|
||||
|
||||
export class GaugePill {
|
||||
private el: HTMLElement | null = null;
|
||||
|
||||
render(container: HTMLElement, gaugeId: string, value: number, max: number): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'gauge-pill';
|
||||
container.appendChild(this.el);
|
||||
this.update(value, max);
|
||||
}
|
||||
|
||||
update(value: number, max: number): void {
|
||||
if (!this.el) return;
|
||||
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||
const gid = this.el.dataset.gaugeId as GaugeType | undefined;
|
||||
const def = gid ? GAUGE_DEFS[gid] : null;
|
||||
const tn = tierNum(value);
|
||||
const tr = tierRate(value);
|
||||
const empty = timeToGain(value, value);
|
||||
const emptyStr = empty === Infinity ? '∞' : fmt(empty);
|
||||
|
||||
this.el.innerHTML = `
|
||||
<div class="gauge-pill-header">
|
||||
${def ? `<span>${def.icon} ${def.label}</span>` : ''}
|
||||
<span class="tier-badge">Tier ${tn} · ±${tr}/tick</span>
|
||||
</div>
|
||||
<div class="gauge-pill-bar">
|
||||
<div class="gauge-pill-fill" style="width:${pct.toFixed(1)}%"></div>
|
||||
</div>
|
||||
<div class="gauge-pill-info">Vide en ${emptyStr}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
449
src/presentation/components/InventaireView.ts
Executable file
449
src/presentation/components/InventaireView.ts
Executable file
@ -0,0 +1,449 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import { GEN_COLORS, RACE_GEN, RACES_DATA } from '@domain/value-objects/Race';
|
||||
import { simulateStock, type SimulationCrossing } from '@domain/services/StockSimulator';
|
||||
import { getDDImage } from '@presentation/helpers/dd-image';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
|
||||
interface StockEntry {
|
||||
m: number;
|
||||
f: number;
|
||||
}
|
||||
|
||||
export class InventaireView {
|
||||
private el: HTMLElement | null = null;
|
||||
private genFilter = 0;
|
||||
private search = '';
|
||||
private inventaire: Record<string, StockEntry> = {};
|
||||
private calcResults: SimulationCrossing[] | null = null;
|
||||
private unusedStock: { race: string; m: number; f: number }[] | null = null;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'inv-view-new';
|
||||
container.appendChild(this.el);
|
||||
|
||||
const stored = this.queryBus.execute<Record<string, StockEntry>>({ type: 'get-inventaire' });
|
||||
if (stored && typeof stored === 'object') {
|
||||
this.inventaire = { ...stored };
|
||||
}
|
||||
|
||||
this.updateDOM();
|
||||
}
|
||||
|
||||
update(): void {}
|
||||
|
||||
destroy(): void {
|
||||
this.saveInventaire();
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
private getStock(race: string): StockEntry {
|
||||
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
|
||||
return this.inventaire[race];
|
||||
}
|
||||
|
||||
private totalStock(): { total: number; races: number } {
|
||||
let total = 0, races = 0;
|
||||
for (const v of Object.values(this.inventaire)) {
|
||||
const sum = (v.m || 0) + (v.f || 0);
|
||||
if (sum > 0) { total += sum; races++; }
|
||||
}
|
||||
return { total, races };
|
||||
}
|
||||
|
||||
private saveInventaire(): void {
|
||||
this.commandBus.execute({ type: 'update-settings', inventaire: { ...this.inventaire } });
|
||||
}
|
||||
|
||||
private updateDOM(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
if (this.calcResults !== null) {
|
||||
this.renderResultsView();
|
||||
} else {
|
||||
this.renderInventoryView();
|
||||
}
|
||||
}
|
||||
|
||||
private renderInventoryView(): void {
|
||||
if (!this.el) return;
|
||||
const { genFilter, search } = this;
|
||||
|
||||
const allRaces: { name: string; gen: number }[] = [];
|
||||
for (const base of ['Rousse', 'Amande', 'Dorée']) allRaces.push({ name: base, gen: 1 });
|
||||
for (const [g, rs] of Object.entries(RACES_DATA)) {
|
||||
for (const r of rs) allRaces.push({ name: r.name, gen: parseInt(g) });
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = allRaces.filter(r =>
|
||||
(genFilter > 0 ? r.gen === genFilter : true) &&
|
||||
(q ? r.name.toLowerCase().includes(q) : true)
|
||||
);
|
||||
const { total } = this.totalStock();
|
||||
|
||||
let html = '';
|
||||
|
||||
/* ── Header ── */
|
||||
html += `<div class="inv-header">
|
||||
<div>
|
||||
<h2 class="inv-title">Inventaire Actuel</h2>
|
||||
<div class="reappro-title-bar"></div>
|
||||
</div>
|
||||
<span class="inv-total" id="inv-summary">${total} Dragons au total</span>
|
||||
</div>`;
|
||||
|
||||
/* ── Search + Gen chips + Action buttons ── */
|
||||
html += `<div class="inv-filters">
|
||||
<div class="inv-filters-row">
|
||||
<div class="inv-search-col">
|
||||
<label class="inv-filter-label">Rechercher une Dragodinde</label>
|
||||
<div class="accoup-search-wrap">
|
||||
<input class="accoup-search" id="inv-search-input" type="text"
|
||||
placeholder="Nom du type…" 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>
|
||||
</div>
|
||||
<div class="inv-gen-col">
|
||||
<label class="inv-filter-label">Filtrer par Génération</label>
|
||||
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
|
||||
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
|
||||
for (let g = 1; g <= 10; g++) {
|
||||
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
|
||||
}
|
||||
html += `</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inv-actions">
|
||||
<button class="inv-btn-reset" id="inv-reset">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">restart_alt</span>
|
||||
Réinitialiser
|
||||
</button>
|
||||
<button class="inv-btn-calc" id="inv-calc">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">science</span>
|
||||
Calculer
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* ── Race grid ── */
|
||||
if (filtered.length === 0) {
|
||||
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
|
||||
} else {
|
||||
html += `<div class="inv-grid">`;
|
||||
for (const race of filtered) {
|
||||
const stock = this.getStock(race.name);
|
||||
const genCol = GEN_COLORS[race.gen] ?? '#888';
|
||||
html += `<div class="inv-card-new">
|
||||
<span class="inv-gen-badge" style="color:${genCol};border-color:${genCol}30;background:${genCol}15">GEN ${race.gen}</span>
|
||||
<div class="inv-avatar">
|
||||
${getDDImage(race.name)}
|
||||
</div>
|
||||
<h4 class="inv-card-name-new">${esc(race.name)}</h4>
|
||||
<div class="inv-gender-row">
|
||||
<div class="inv-gender-input male">
|
||||
<span class="inv-gender-symbol">♂</span>
|
||||
<input type="number" min="0" value="${stock.m}" data-race="${esc(race.name)}" data-gender="m">
|
||||
</div>
|
||||
<div class="inv-gender-input female">
|
||||
<span class="inv-gender-symbol">♀</span>
|
||||
<input type="number" min="0" value="${stock.f}" data-race="${esc(race.name)}" data-gender="f">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindInventoryEvents();
|
||||
|
||||
if (search) {
|
||||
const inp = this.el.querySelector<HTMLInputElement>('#inv-search-input');
|
||||
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
||||
}
|
||||
}
|
||||
|
||||
private renderResultsView(): void {
|
||||
if (!this.el) return;
|
||||
let html = '';
|
||||
|
||||
/* Back button */
|
||||
html += `<div class="reappro-top-bar">
|
||||
<button class="reappro-back-btn" id="inv-back">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
|
||||
Retour à l'inventaire
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
html += this.renderCalcResults();
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindResultsEvents();
|
||||
}
|
||||
|
||||
private renderCalcResults(): string {
|
||||
const crossings = this.calcResults!;
|
||||
|
||||
if (crossings.length === 0) {
|
||||
return `<div class="inv-calc-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:48px;color:var(--md-outline-variant)">egg</span>
|
||||
<p>Aucun croisement possible avec le stock actuel.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Group by gen
|
||||
const byGen = new Map<number, SimulationCrossing[]>();
|
||||
for (const c of crossings) {
|
||||
if (!byGen.has(c.gen)) byGen.set(c.gen, []);
|
||||
byGen.get(c.gen)!.push(c);
|
||||
}
|
||||
const gens = Array.from(byGen.keys()).sort((a, b) => a - b);
|
||||
const totalBabies = crossings.reduce((s, c) => s + c.count, 0);
|
||||
|
||||
let html = '';
|
||||
|
||||
/* Glass panel wrapper */
|
||||
html += `<div class="inv-calc-panel">`;
|
||||
|
||||
/* Panel header */
|
||||
html += `<div class="inv-calc-header">
|
||||
<div class="inv-calc-header-left">
|
||||
<span class="material-symbols-outlined inv-calc-icon">science</span>
|
||||
<h2 class="inv-calc-title">Calculateur de Croisements</h2>
|
||||
</div>
|
||||
<div class="inv-calc-stats">
|
||||
<span class="inv-calc-stat-value">${totalBabies}</span>
|
||||
<span class="inv-calc-stat-label">bébés possibles sur ${gens.length} génération${gens.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* Crossings by gen */
|
||||
for (let gi = 0; gi < gens.length; gi++) {
|
||||
const g = gens[gi];
|
||||
const gCrossings = byGen.get(g)!;
|
||||
const genTotal = gCrossings.reduce((s, c) => s + c.count, 0);
|
||||
const genCol = GEN_COLORS[g] ?? '#888';
|
||||
|
||||
html += `<div class="inv-calc-step">
|
||||
<div class="inv-calc-step-label">
|
||||
<div class="inv-calc-step-badge-col">
|
||||
<span class="inv-calc-step-num" style="background:${genCol}">0${gi + 1}</span>
|
||||
<span class="inv-calc-step-subtitle">Étape</span>
|
||||
</div>
|
||||
<h3 class="inv-calc-step-title">Génération ${g}</h3>
|
||||
<span class="inv-calc-step-count">${genTotal} bébé${genTotal > 1 ? 's' : ''}</span>
|
||||
</div>`;
|
||||
|
||||
for (const c of gCrossings) {
|
||||
html += `<div class="inv-calc-crossing">`;
|
||||
|
||||
// Parent A
|
||||
html += this.renderCalcParent(c.parentA, c.pAMale, c.pAFemale);
|
||||
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
|
||||
// Parent B
|
||||
html += this.renderCalcParent(c.parentB, c.pBMale, c.pBFemale);
|
||||
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
|
||||
// Baby
|
||||
const babyGen = RACE_GEN[c.baby] ?? 0;
|
||||
html += `<div class="inv-calc-baby">
|
||||
<div class="inv-calc-baby-avatar">
|
||||
${getDDImage(c.baby)}
|
||||
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
|
||||
</div>
|
||||
<span class="inv-calc-baby-name">${esc(c.baby)}</span>
|
||||
<span class="inv-calc-baby-qty">×${c.count}</span>
|
||||
</div>`;
|
||||
|
||||
html += `</div>`; // .inv-calc-crossing
|
||||
}
|
||||
|
||||
html += `</div>`; // .inv-calc-step
|
||||
}
|
||||
|
||||
/* Save workflow button */
|
||||
html += `<div class="inv-calc-save-row">
|
||||
<button class="reappro-save-btn" id="inv-save">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">save</span>
|
||||
Sauvegarder le workflow
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
html += `</div>`; // .inv-calc-panel
|
||||
|
||||
/* Unused stock */
|
||||
if (this.unusedStock && this.unusedStock.length > 0) {
|
||||
html += `<div class="inv-unused-section">
|
||||
<div class="inv-unused-header">
|
||||
<h3 class="inv-unused-title">Dragodindes Restantes</h3>
|
||||
</div>
|
||||
<div class="inv-unused-grid">`;
|
||||
for (const u of this.unusedStock) {
|
||||
const gen = RACE_GEN[u.race] ?? 1;
|
||||
html += `<div class="inv-unused-card">
|
||||
<div class="inv-unused-avatar">
|
||||
${getDDImage(u.race)}
|
||||
</div>
|
||||
<div class="inv-unused-info">
|
||||
<p class="inv-unused-name">${esc(u.race)}</p>
|
||||
<p class="inv-unused-qty">
|
||||
${u.m > 0 ? `<span style="color:#50a0ff;font-weight:800">♂ ${u.m}</span>` : ''}
|
||||
${u.f > 0 ? `<span style="color:#ff64a0;font-weight:800">♀ ${u.f}</span>` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderCalcParent(race: string, maleUsed: number, femaleUsed: number): string {
|
||||
const parts: string[] = [];
|
||||
if (maleUsed > 0) parts.push(`<span style="color:#50a0ff">♂ ${maleUsed}</span>`);
|
||||
if (femaleUsed > 0) parts.push(`<span style="color:#ff64a0">♀ ${femaleUsed}</span>`);
|
||||
return `<div class="inv-calc-parent">
|
||||
<div class="inv-calc-parent-avatar">
|
||||
${getDDImage(race)}
|
||||
</div>
|
||||
<span class="inv-calc-parent-name">${esc(race)}</span>
|
||||
<span class="inv-calc-parent-gender">${parts.join(' ')}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private bindInventoryEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Search
|
||||
const searchInput = this.el.querySelector<HTMLInputElement>('#inv-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => { this.search = searchInput.value; this.updateDOM(); });
|
||||
}
|
||||
const clearBtn = this.el.querySelector('.accoup-search-clear');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => { this.search = ''; this.updateDOM(); });
|
||||
}
|
||||
|
||||
// Gen filter chips
|
||||
this.el.querySelectorAll('.accoup-gen-chip[data-gen]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
|
||||
this.updateDOM();
|
||||
});
|
||||
});
|
||||
|
||||
// Stock inputs — focus/blur
|
||||
this.el.querySelectorAll('.inv-gender-input input').forEach(inp => {
|
||||
const el = inp as HTMLInputElement;
|
||||
let prev = el.value;
|
||||
el.addEventListener('focus', () => { prev = el.value; el.value = ''; });
|
||||
el.addEventListener('input', () => {
|
||||
if (el.value === '') return;
|
||||
const race = el.dataset.race!;
|
||||
const gender = el.dataset.gender as 'm' | 'f';
|
||||
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
|
||||
this.inventaire[race][gender] = Math.max(0, parseInt(el.value) || 0);
|
||||
const { total } = this.totalStock();
|
||||
const summary = this.el?.querySelector('#inv-summary');
|
||||
if (summary) summary.textContent = `${total} Dragons au total`;
|
||||
});
|
||||
el.addEventListener('blur', () => {
|
||||
if (el.value === '') el.value = prev;
|
||||
const race = el.dataset.race!;
|
||||
const gender = el.dataset.gender as 'm' | 'f';
|
||||
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
|
||||
this.inventaire[race][gender] = Math.max(0, parseInt(el.value) || 0);
|
||||
el.value = String(this.inventaire[race][gender]);
|
||||
this.saveInventaire();
|
||||
const { total } = this.totalStock();
|
||||
const summary = this.el?.querySelector('#inv-summary');
|
||||
if (summary) summary.textContent = `${total} Dragons au total`;
|
||||
});
|
||||
});
|
||||
|
||||
// Reset
|
||||
const resetBtn = this.el.querySelector('#inv-reset');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
this.inventaire = {};
|
||||
this.calcResults = null;
|
||||
this.unusedStock = null;
|
||||
this.saveInventaire();
|
||||
this.updateDOM();
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate
|
||||
const calcBtn = this.el.querySelector('#inv-calc');
|
||||
if (calcBtn) {
|
||||
calcBtn.addEventListener('click', () => {
|
||||
this.calcInventaire();
|
||||
this.updateDOM();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bindResultsEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Back button — retour à l'inventaire sans réinitialiser
|
||||
const backBtn = this.el.querySelector('#inv-back');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
this.calcResults = null;
|
||||
this.unusedStock = null;
|
||||
this.updateDOM();
|
||||
});
|
||||
}
|
||||
|
||||
// Save workflow
|
||||
const saveBtn = this.el.querySelector<HTMLButtonElement>('#inv-save');
|
||||
if (saveBtn && this.calcResults && this.calcResults.length > 0) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const crossings = this.calcResults!;
|
||||
const topCrossing = crossings.reduce((best, c) => c.gen > best.gen ? c : best, crossings[0]);
|
||||
const target = topCrossing.baby;
|
||||
const qty = topCrossing.count;
|
||||
const materials = Object.entries(this.inventaire)
|
||||
.filter(([, e]) => e.m > 0 || e.f > 0)
|
||||
.map(([race, e]) => ({ race, m: e.m, f: e.f }));
|
||||
const steps = crossings.map(c => ({
|
||||
baby: c.baby,
|
||||
parentA: c.parentA,
|
||||
parentB: c.parentB,
|
||||
couples: c.count,
|
||||
gen: c.gen,
|
||||
}));
|
||||
|
||||
this.commandBus.execute({
|
||||
type: 'save-workflow',
|
||||
target,
|
||||
qty,
|
||||
materials,
|
||||
steps,
|
||||
repro: {},
|
||||
});
|
||||
|
||||
saveBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px">check</span> Sauvegardé !';
|
||||
setTimeout(() => {
|
||||
saveBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px">save</span> Sauvegarder le workflow';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Simulation ── */
|
||||
private calcInventaire(): void {
|
||||
const { crossings, unusedStock } = simulateStock(this.inventaire);
|
||||
this.calcResults = crossings;
|
||||
this.unusedStock = unusedStock;
|
||||
}
|
||||
}
|
||||
403
src/presentation/components/ParametresView.ts
Executable file
403
src/presentation/components/ParametresView.ts
Executable file
@ -0,0 +1,403 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { SettingsResult } from '@application/queries/GetSettings';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
|
||||
const NTFY_BASE = 'https://ntfy.mickael-pol.fr';
|
||||
const NTFY_REDIRECT = 'https://ntfy-redirect.mickael-pol.fr';
|
||||
|
||||
const SOUND_OPTIONS: { value: string; label: string; icon: string }[] = [
|
||||
{ value: 'arpege', label: 'Arpège', icon: 'music_note' },
|
||||
{ value: 'pulse', label: 'Pulsation', icon: 'pulse_alert' },
|
||||
{ value: 'fanfare', label: 'Fanfare', icon: 'celebration' },
|
||||
{ value: 'cloche', label: 'Cloche', icon: 'notifications_active' },
|
||||
];
|
||||
|
||||
export class ParametresView {
|
||||
private el: HTMLElement | null = null;
|
||||
private modal: HTMLElement | null = null;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private playSound?: (name: string) => void,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'param-view';
|
||||
container.appendChild(this.el);
|
||||
this.updateDOM();
|
||||
}
|
||||
|
||||
update(): void {}
|
||||
|
||||
destroy(): void {
|
||||
this.modal?.remove();
|
||||
this.modal = null;
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
private getSettings(): SettingsResult {
|
||||
return this.queryBus.execute<SettingsResult>({ type: 'get-settings' });
|
||||
}
|
||||
|
||||
private updateDOM(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
const { alarmSound, notifsEnabled, ntfyTopic } = this.getSettings();
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── Hero ──────────────────────────────────────────────────────
|
||||
html += `<div class="param-hero">
|
||||
<div>
|
||||
<h2 class="param-hero-title">Param\u00e8tres</h2>
|
||||
<p class="param-hero-sub">Configuration de l'application et des notifications.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── Son d'alarme ──────────────────────────────────────────────
|
||||
html += `<div class="param-section">
|
||||
<div class="param-section-header">
|
||||
<span class="material-symbols-outlined param-section-icon">volume_up</span>
|
||||
<h3 class="param-section-title">Son d'alarme</h3>
|
||||
</div>
|
||||
<p class="param-section-desc">Choisissez le son joué lorsqu'un enclos termine sa session.</p>
|
||||
<div class="param-sound-grid">`;
|
||||
|
||||
for (const opt of SOUND_OPTIONS) {
|
||||
const isActive = alarmSound === opt.value ? ' active' : '';
|
||||
html += `<button class="param-sound-card${isActive}" data-sound="${esc(opt.value)}">
|
||||
<span class="material-symbols-outlined param-sound-icon">${opt.icon}</span>
|
||||
<span class="param-sound-label">${esc(opt.label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
html += `</div>
|
||||
<button class="param-test-btn" id="param-test-sound">
|
||||
<span class="material-symbols-outlined">play_circle</span>
|
||||
Tester le son
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
// ── Notifications PC ──────────────────────────────────────────
|
||||
html += `<div class="param-section">
|
||||
<div class="param-section-header">
|
||||
<span class="material-symbols-outlined param-section-icon">notifications</span>
|
||||
<h3 class="param-section-title">Notifications PC</h3>
|
||||
</div>
|
||||
<p class="param-section-desc">Recevez une notification Windows quand un enclos est pr\u00eat.</p>
|
||||
<div class="param-toggle-row">
|
||||
<span class="param-toggle-label">${notifsEnabled ? 'Notifications activ\u00e9es' : 'Notifications d\u00e9sactiv\u00e9es'}</span>
|
||||
<button class="param-toggle${notifsEnabled ? ' active' : ''}" id="param-notifs-toggle">
|
||||
<span class="param-toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── Notifications Mobile ──────────────────────────────────────
|
||||
const mobileActive = ntfyTopic ? ' active' : '';
|
||||
html += `<div class="param-section">
|
||||
<div class="param-section-header">
|
||||
<span class="material-symbols-outlined param-section-icon">phone_android</span>
|
||||
<h3 class="param-section-title">Notifications Mobiles</h3>
|
||||
</div>
|
||||
<p class="param-section-desc">Recevez une alerte sur votre t\u00e9l\u00e9phone via ntfy, m\u00eame loin de votre PC.</p>
|
||||
<div class="param-mobile-status">
|
||||
<span class="param-mobile-dot${mobileActive}"></span>
|
||||
<span class="param-mobile-text">${ntfyTopic ? 'Connect\u00e9 — notifications actives' : 'Non configur\u00e9'}</span>
|
||||
</div>
|
||||
<button class="param-mobile-btn${mobileActive}" id="param-ntfy-btn">
|
||||
<span class="material-symbols-outlined">${ntfyTopic ? 'settings' : 'add_circle'}</span>
|
||||
${ntfyTopic ? 'G\u00e9rer la configuration' : 'Activer les notifications mobiles'}
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
// ── Données ──────────────────────────────────────────────────
|
||||
html += `<div class="param-section">
|
||||
<div class="param-section-header">
|
||||
<span class="material-symbols-outlined param-section-icon">database</span>
|
||||
<h3 class="param-section-title">Donn\u00e9es</h3>
|
||||
</div>
|
||||
<p class="param-section-desc">Exportez ou importez toutes vos donn\u00e9es (enclos, dragodindes, statistiques, workflows).</p>
|
||||
<div class="param-data-btns">
|
||||
<button class="param-data-btn" id="param-export-data">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
Exporter les donn\u00e9es
|
||||
</button>
|
||||
<button class="param-data-btn param-data-btn-import" id="param-import-data">
|
||||
<span class="material-symbols-outlined">upload</span>
|
||||
Importer les donn\u00e9es
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Sound cards
|
||||
this.el.querySelectorAll<HTMLElement>('.param-sound-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const sound = card.dataset['sound']!;
|
||||
this.commandBus.execute({ type: 'update-settings', alarmSound: sound });
|
||||
this.updateDOM();
|
||||
});
|
||||
});
|
||||
|
||||
// Test sound
|
||||
this.el.querySelector('#param-test-sound')?.addEventListener('click', () => {
|
||||
const { alarmSound } = this.getSettings();
|
||||
this.playSound?.(alarmSound);
|
||||
});
|
||||
|
||||
// PC notifications toggle
|
||||
this.el.querySelector('#param-notifs-toggle')?.addEventListener('click', () => {
|
||||
const { notifsEnabled } = this.getSettings();
|
||||
this.commandBus.execute({ type: 'update-settings', notifsEnabled: !notifsEnabled });
|
||||
this.updateDOM();
|
||||
});
|
||||
|
||||
// Ntfy modal
|
||||
this.el.querySelector('#param-ntfy-btn')?.addEventListener('click', () => {
|
||||
this.openNtfyModal();
|
||||
});
|
||||
|
||||
// Export data
|
||||
this.el.querySelector('#param-export-data')?.addEventListener('click', () => this.exportData());
|
||||
|
||||
// Import data
|
||||
this.el.querySelector('#param-import-data')?.addEventListener('click', () => this.importData());
|
||||
}
|
||||
|
||||
/* ══ Modal ntfy ══ */
|
||||
|
||||
private openNtfyModal(): void {
|
||||
if (this.modal) { this.modal.classList.remove('hidden'); this.renderNtfyModal(); return; }
|
||||
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'param-modal-overlay';
|
||||
this.modal.innerHTML = `
|
||||
<div class="param-modal-box">
|
||||
<div class="param-modal-header">
|
||||
<div class="param-modal-header-left">
|
||||
<span class="material-symbols-outlined" style="color:var(--md-primary)">phone_android</span>
|
||||
<h2 class="param-modal-title">Notifications mobiles</h2>
|
||||
</div>
|
||||
<button class="param-modal-close" id="ntfy-modal-close">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="param-modal-body" id="ntfy-modal-body"></div>
|
||||
<div class="param-modal-footer">
|
||||
<button class="param-modal-btn-ghost" id="ntfy-modal-footer-close">Fermer</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(this.modal);
|
||||
|
||||
this.modal.querySelector('#ntfy-modal-close')?.addEventListener('click', () => this.closeNtfyModal());
|
||||
this.modal.querySelector('#ntfy-modal-footer-close')?.addEventListener('click', () => this.closeNtfyModal());
|
||||
this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.closeNtfyModal(); });
|
||||
|
||||
this.renderNtfyModal();
|
||||
}
|
||||
|
||||
private closeNtfyModal(): void {
|
||||
this.modal?.classList.add('hidden');
|
||||
}
|
||||
|
||||
private renderNtfyModal(): void {
|
||||
const body = this.modal?.querySelector('#ntfy-modal-body');
|
||||
if (!body) return;
|
||||
|
||||
const { ntfyTopic } = this.getSettings();
|
||||
|
||||
if (!ntfyTopic) {
|
||||
body.innerHTML = `
|
||||
<div class="param-ntfy-intro">
|
||||
<div class="param-ntfy-intro-card">
|
||||
<span class="material-symbols-outlined" style="font-size:36px;color:var(--md-primary)">notifications_active</span>
|
||||
<p>Recevez une alerte sur votre t\u00e9l\u00e9phone quand un enclos est pr\u00eat, m\u00eame si votre PC est loin !</p>
|
||||
</div>
|
||||
<button class="param-ntfy-activate" id="ntfy-activate">
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
Activer les notifications mobiles
|
||||
</button>
|
||||
</div>`;
|
||||
body.querySelector('#ntfy-activate')?.addEventListener('click', () => this.activateNtfy());
|
||||
return;
|
||||
}
|
||||
|
||||
// QR codes
|
||||
const ntfyPlayStore = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
|
||||
const ntfyAppStore = 'https://apps.apple.com/app/ntfy/id1625396347';
|
||||
const qrDownload = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(ntfyPlayStore)}`;
|
||||
const redirectUrl = `${NTFY_REDIRECT}/?t=${encodeURIComponent(ntfyTopic)}&s=${encodeURIComponent(NTFY_BASE.replace(/^https?:\/\//, ''))}&n=dd-timer`;
|
||||
const qrSubscribe = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(redirectUrl)}`;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="param-ntfy-steps">
|
||||
<div class="param-ntfy-step">
|
||||
<div class="param-ntfy-step-badge">1</div>
|
||||
<div class="param-ntfy-step-content">
|
||||
<h4 class="param-ntfy-step-title">Installer l'app ntfy</h4>
|
||||
<p class="param-ntfy-step-desc">Scannez ce QR code ou cherchez <strong>ntfy</strong> sur le
|
||||
<a href="${ntfyPlayStore}" target="_blank">Play Store</a> /
|
||||
<a href="${ntfyAppStore}" target="_blank">App Store</a></p>
|
||||
<div class="param-ntfy-qr-wrap">
|
||||
<img src="${qrDownload}" width="100" height="100" alt="T\u00e9l\u00e9charger ntfy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-ntfy-step">
|
||||
<div class="param-ntfy-step-badge">2</div>
|
||||
<div class="param-ntfy-step-content">
|
||||
<h4 class="param-ntfy-step-title">S'abonner aux notifications</h4>
|
||||
<p class="param-ntfy-step-desc">Scannez ce QR code avec l'appareil photo de votre t\u00e9l\u00e9phone pour ajouter automatiquement les notifications.</p>
|
||||
<div class="param-ntfy-qr-wrap param-ntfy-qr-main">
|
||||
<img src="${qrSubscribe}" width="150" height="150" alt="S'abonner aux notifications">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-ntfy-actions">
|
||||
<button class="param-ntfy-test-btn" id="ntfy-test">
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
Tester
|
||||
</button>
|
||||
<button class="param-ntfy-deactivate-btn" id="ntfy-deactivate">
|
||||
<span class="material-symbols-outlined">link_off</span>
|
||||
D\u00e9sactiver
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
body.querySelector('#ntfy-test')?.addEventListener('click', () => this.testNtfy());
|
||||
body.querySelector('#ntfy-deactivate')?.addEventListener('click', () => this.deactivateNtfy());
|
||||
}
|
||||
|
||||
private activateNtfy(): void {
|
||||
const topic = 'dd-' + Math.random().toString(36).slice(2, 8) + '-' + Date.now().toString(36).slice(-4);
|
||||
this.commandBus.execute({ type: 'update-settings', ntfyTopic: topic });
|
||||
this.updateDOM();
|
||||
this.renderNtfyModal();
|
||||
}
|
||||
|
||||
private deactivateNtfy(): void {
|
||||
this.commandBus.execute({ type: 'update-settings', ntfyTopic: '' });
|
||||
this.updateDOM();
|
||||
this.renderNtfyModal();
|
||||
}
|
||||
|
||||
private testNtfy(): void {
|
||||
const { ntfyTopic } = this.getSettings();
|
||||
if (!ntfyTopic) return;
|
||||
const url = `${NTFY_BASE}/${ntfyTopic}`;
|
||||
(window as any).electronAPI?.sendNtfy?.(url, 'Test alarme', 'Ceci est un test de la notification mobile Obsidienne !');
|
||||
}
|
||||
|
||||
/* ══ Backup / Restore ══ */
|
||||
|
||||
private async exportData(): Promise<void> {
|
||||
const api = (window as any).electronAPI;
|
||||
if (!api?.loadData || !api?.exportFile) return;
|
||||
|
||||
try {
|
||||
const raw = await api.loadData();
|
||||
if (!raw) {
|
||||
Toast.show('error', 'Aucune donnée à exporter.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = await api.getVersion?.() ?? 'unknown';
|
||||
const backup = {
|
||||
app: 'obsidienne',
|
||||
version,
|
||||
exportedAt: new Date().toISOString(),
|
||||
data: JSON.parse(raw),
|
||||
};
|
||||
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const ok = await api.exportFile(JSON.stringify(backup, null, 2), `dd-timer-backup-${date}.json`);
|
||||
if (ok) {
|
||||
Toast.show('success', 'Données exportées avec succès.');
|
||||
}
|
||||
} catch {
|
||||
Toast.show('error', 'Erreur lors de l\'export des données.');
|
||||
}
|
||||
}
|
||||
|
||||
private async importData(): Promise<void> {
|
||||
const api = (window as any).electronAPI;
|
||||
if (!api?.importFile || !api?.saveData) return;
|
||||
|
||||
try {
|
||||
const raw = await api.importFile();
|
||||
if (!raw) return; // dialogue annulé
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation structurelle des données importées
|
||||
const validateEnclosData = (data: any): boolean => {
|
||||
if (!data || typeof data !== 'object' || data === null) return false;
|
||||
if (!Array.isArray(data.enclos)) return false;
|
||||
return data.enclos.every((enc: any) =>
|
||||
enc && typeof enc === 'object' &&
|
||||
typeof enc.id === 'number' &&
|
||||
typeof enc.name === 'string' &&
|
||||
Array.isArray(enc.dragodindes) &&
|
||||
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
|
||||
enc.timer && typeof enc.timer === 'object',
|
||||
);
|
||||
};
|
||||
|
||||
// Validation du format backup
|
||||
if ((parsed.app === 'obsidienne' || parsed.app === 'minuteur-dragodinde') && parsed.data && typeof parsed.data === 'object' && parsed.data !== null) {
|
||||
if (!validateEnclosData(parsed.data)) {
|
||||
Toast.show('error', 'Le backup contient des données corrompues ou incomplètes.');
|
||||
return;
|
||||
}
|
||||
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
|
||||
const ok = await ConfirmModal.show(
|
||||
'Importer les données',
|
||||
`Ce backup date du ${date} (v${parsed.version ?? '?'}). Toutes vos données actuelles seront remplacées. Continuer ?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
api.saveData(JSON.stringify(parsed.data));
|
||||
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
|
||||
if (!validateEnclosData(parsed)) {
|
||||
Toast.show('error', 'Le fichier contient des données corrompues ou incomplètes.');
|
||||
return;
|
||||
}
|
||||
const ok = await ConfirmModal.show(
|
||||
'Importer les données',
|
||||
'Toutes vos données actuelles seront remplacées. Continuer ?',
|
||||
);
|
||||
if (!ok) return;
|
||||
api.saveData(JSON.stringify(parsed));
|
||||
} else {
|
||||
Toast.show('error', 'Format de fichier non reconnu.');
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.show('success', 'Données importées. Rechargement...');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch {
|
||||
Toast.show('error', 'Erreur lors de l\'import des données.');
|
||||
}
|
||||
}
|
||||
}
|
||||
471
src/presentation/components/ReapproView.ts
Executable file
471
src/presentation/components/ReapproView.ts
Executable file
@ -0,0 +1,471 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_RECIPES } from '@domain/value-objects/Race';
|
||||
import { getDDImage } from '@presentation/helpers/dd-image';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
|
||||
interface ApproNeeds {
|
||||
total: number;
|
||||
m: number;
|
||||
f: number;
|
||||
}
|
||||
|
||||
interface ApproStep {
|
||||
baby: string;
|
||||
parentA: string;
|
||||
parentB: string;
|
||||
couples: number;
|
||||
gen: number;
|
||||
}
|
||||
|
||||
interface ApproState {
|
||||
target: string;
|
||||
qty: number;
|
||||
repro: Record<string, number>;
|
||||
inverted: Record<string, boolean>;
|
||||
genFilter: number;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export class ReapproView {
|
||||
private el: HTMLElement | null = null;
|
||||
private approState: ApproState = { target: '', qty: 1, repro: {}, inverted: {}, genFilter: 0, search: '' };
|
||||
private dirty = true;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'reappro-view-new';
|
||||
container.appendChild(this.el);
|
||||
this.dirty = true; this.update();
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.el || !this.dirty) return;
|
||||
this.dirty = false;
|
||||
const { target } = this.approState;
|
||||
|
||||
if (target) {
|
||||
this.renderResults();
|
||||
} else {
|
||||
this.renderTargetSelection();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
/* ── Target selection ── */
|
||||
private renderTargetSelection(): void {
|
||||
if (!this.el) return;
|
||||
const { genFilter, search } = this.approState;
|
||||
|
||||
const allRaces: { name: string; gen: number }[] = [];
|
||||
for (const [g, rs] of Object.entries(RACES_DATA)) {
|
||||
const gen = parseInt(g);
|
||||
if (gen < 2) continue;
|
||||
for (const r of rs) allRaces.push({ name: r.name, gen });
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = allRaces.filter(r =>
|
||||
(genFilter > 0 ? r.gen === genFilter : true) &&
|
||||
(q ? r.name.toLowerCase().includes(q) : true)
|
||||
);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Header
|
||||
html += `<div class="reappro-section-header">
|
||||
<div>
|
||||
<h2 class="reappro-section-title">Sélectionne ta cible</h2>
|
||||
<div class="reappro-title-bar"></div>
|
||||
</div>
|
||||
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
|
||||
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
|
||||
for (let g = 2; g <= 10; g++) {
|
||||
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
|
||||
// Search
|
||||
html += `<div class="accoup-search-wrap">
|
||||
<input class="accoup-search" id="appro-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
|
||||
if (filtered.length === 0) {
|
||||
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
|
||||
} else {
|
||||
html += `<div class="accoup-race-grid">`;
|
||||
for (const race of filtered) {
|
||||
const genCol = GEN_COLORS[race.gen] ?? '#888';
|
||||
html += `<div class="accoup-race-card" 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>`;
|
||||
}
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindTargetSelectionEvents();
|
||||
|
||||
if (search) {
|
||||
const inp = this.el.querySelector<HTMLInputElement>('#appro-search-input');
|
||||
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
||||
}
|
||||
}
|
||||
|
||||
private bindTargetSelectionEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
const searchInput = this.el.querySelector<HTMLInputElement>('#appro-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => {
|
||||
this.approState.search = searchInput.value;
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
const clearBtn = this.el.querySelector('.accoup-search-clear');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.approState.search = '';
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.approState.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
this.el.querySelectorAll('.accoup-race-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
this.approState.target = (card as HTMLElement).dataset.race!;
|
||||
this.approState.qty = 1;
|
||||
this.approState.repro = {};
|
||||
this.approState.inverted = {};
|
||||
this.approState.search = '';
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Results view ── */
|
||||
private renderResults(): void {
|
||||
if (!this.el) return;
|
||||
const { target, qty } = this.approState;
|
||||
const targetGen = RACE_GEN[target] ?? 0;
|
||||
const { materials, steps } = this.calcAppro(target, qty);
|
||||
|
||||
// Group steps by generation
|
||||
const stepsByGen = new Map<number, ApproStep[]>();
|
||||
for (const step of steps) {
|
||||
const list = stepsByGen.get(step.gen) ?? [];
|
||||
list.push(step);
|
||||
stepsByGen.set(step.gen, list);
|
||||
}
|
||||
const sortedGens = [...stepsByGen.keys()].sort((a, b) => a - b);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Top bar: back + quantity (top-left)
|
||||
html += `<div class="reappro-top-bar">
|
||||
<button class="reappro-back-btn" id="appro-back">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
|
||||
Retour
|
||||
</button>
|
||||
<div class="reappro-qty-wrap">
|
||||
<label class="accoup-center-label">Quantité</label>
|
||||
<input class="accoup-center-input" id="appro-qty" type="number" min="1" value="${qty}" style="width:70px">
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Materials (Gen 1)
|
||||
if (materials.length > 0) {
|
||||
const totalMat = materials.reduce((s, m) => s + m.m + m.f, 0);
|
||||
html += `<div class="reappro-step-panel reappro-step-primary">`;
|
||||
html += `<div class="reappro-step-header reappro-step-header-primary">
|
||||
<h3 class="reappro-step-title">
|
||||
<span class="reappro-step-badge reappro-badge-primary">1</span>
|
||||
MATIÈRES PREMIÈRES — GÉNÉRATION 1
|
||||
</h3>
|
||||
<span class="reappro-step-count">Total : ${totalMat} dragodindes requises</span>
|
||||
</div>`;
|
||||
html += `<div class="reappro-materials-grid">`;
|
||||
for (const mat of materials) {
|
||||
html += this.renderMaterialCard(mat.race, mat.m, mat.f);
|
||||
}
|
||||
html += `</div></div>`;
|
||||
|
||||
// Arrow separator
|
||||
html += `<div class="reappro-arrow-sep">
|
||||
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Steps grouped by generation
|
||||
let stepNum = 2;
|
||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||
const gen = sortedGens[gi];
|
||||
const genSteps = stepsByGen.get(gen)!;
|
||||
const isFinalGen = gi === sortedGens.length - 1;
|
||||
const genCol = GEN_COLORS[gen] ?? '#888';
|
||||
|
||||
const panelClass = isFinalGen ? 'reappro-step-panel reappro-step-final' : 'reappro-step-panel';
|
||||
const headerClass = isFinalGen ? 'reappro-step-header reappro-step-header-final' : 'reappro-step-header';
|
||||
const badgeClass = isFinalGen ? 'reappro-step-badge reappro-badge-primary' : 'reappro-step-badge';
|
||||
|
||||
html += `<div class="${panelClass}">`;
|
||||
html += `<div class="${headerClass}">
|
||||
<h3 class="reappro-step-title">
|
||||
<span class="${badgeClass}">${stepNum}</span>
|
||||
${isFinalGen ? 'ÉTAPE FINALE' : 'CROISEMENTS'} — GÉNÉRATION ${gen}
|
||||
</h3>
|
||||
<span class="reappro-step-count">${genSteps.length} croisement${genSteps.length > 1 ? 's' : ''}</span>
|
||||
</div>`;
|
||||
|
||||
html += `<div class="reappro-crossings-grid">`;
|
||||
for (const step of genSteps) {
|
||||
const invKey = step.baby;
|
||||
const isInverted = this.approState.inverted[invKey] ?? false;
|
||||
const reproCount = this.approState.repro[invKey] ?? 0;
|
||||
const isLast = isFinalGen && genSteps.length === 1;
|
||||
|
||||
html += `<div class="reappro-crossing-card${isLast ? ' reappro-crossing-final' : ''}">`;
|
||||
html += `<div class="reappro-crossing-row">`;
|
||||
|
||||
// Parents
|
||||
const pA = isInverted ? step.parentB : step.parentA;
|
||||
const pB = isInverted ? step.parentA : step.parentB;
|
||||
html += this.renderCrossingParent(pA, '♂', step.couples);
|
||||
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
|
||||
html += this.renderCrossingParent(pB, '♀', step.couples);
|
||||
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
|
||||
|
||||
// Baby result
|
||||
const babyGen = RACE_GEN[step.baby] ?? 0;
|
||||
html += `<div class="reappro-crossing-baby${isLast ? ' reappro-crossing-baby-final' : ''}">
|
||||
<div class="reappro-baby-avatar">
|
||||
${getDDImage(step.baby)}
|
||||
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
|
||||
</div>
|
||||
<span class="reappro-baby-name">${esc(step.baby)}</span>
|
||||
<span class="reappro-baby-qty">×${step.couples}</span>
|
||||
</div>`;
|
||||
|
||||
html += `</div>`; // .reappro-crossing-row
|
||||
|
||||
// Controls row
|
||||
html += `<div class="reappro-crossing-controls">
|
||||
<label class="reappro-repro-label">
|
||||
<span style="font-size:11px;color:var(--md-on-surface-variant)">Reproducteurs</span>
|
||||
<input type="number" class="reappro-repro-input" data-race="${esc(invKey)}" min="0" value="${reproCount}">
|
||||
</label>
|
||||
<button class="reappro-invert-btn" data-race="${esc(invKey)}" title="Inverser ♂/♀">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">swap_horiz</span>
|
||||
</button>
|
||||
<span class="reappro-couples-badge">${step.couples} couple${step.couples > 1 ? 's' : ''}</span>
|
||||
</div>`;
|
||||
|
||||
html += `</div>`; // .reappro-crossing-card
|
||||
}
|
||||
html += `</div>`; // .reappro-crossings-grid
|
||||
html += `</div>`; // .reappro-step-panel
|
||||
|
||||
stepNum++;
|
||||
|
||||
// Arrow between gen panels (not after last)
|
||||
if (!isFinalGen) {
|
||||
html += `<div class="reappro-arrow-sep">
|
||||
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky target bar at bottom with save button
|
||||
html += `<div class="reappro-target-bar-sticky">
|
||||
<div class="reappro-target-info">
|
||||
<div class="reappro-target-icon">
|
||||
<span class="material-symbols-outlined mso-fill">auto_awesome</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="reappro-target-label">Cible de réapprovisionnement</div>
|
||||
<div class="reappro-target-name">${esc(target)} (Génération ${targetGen})</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="reappro-save-btn" id="appro-save">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">save</span>
|
||||
Sauvegarder ce plan
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindResultsEvents();
|
||||
}
|
||||
|
||||
private renderMaterialCard(race: string, m: number, f: number): string {
|
||||
const gen = RACE_GEN[race] ?? 1;
|
||||
const genCol = GEN_COLORS[gen] ?? '#888';
|
||||
const parts: string[] = [];
|
||||
if (m > 0) parts.push(`<span class="reappro-mat-gender" style="color:#50a0ff">♂ ${m}</span>`);
|
||||
if (f > 0) parts.push(`<span class="reappro-mat-gender" style="color:#ff64a0">♀ ${f}</span>`);
|
||||
|
||||
return `<div class="reappro-material-card">
|
||||
<div class="reappro-mat-avatar">
|
||||
${getDDImage(race)}
|
||||
</div>
|
||||
<span class="reappro-mat-name">${esc(race)}</span>
|
||||
<div class="reappro-mat-qty">${parts.join(' ')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderCrossingParent(race: string, gender: string, qty: number): string {
|
||||
const genderColor = gender === '♂' ? '#50a0ff' : '#ff64a0';
|
||||
return `<div class="reappro-crossing-parent">
|
||||
<div class="reappro-crossing-parent-avatar">
|
||||
${getDDImage(race)}
|
||||
</div>
|
||||
<span class="reappro-crossing-parent-name">${esc(race)}</span>
|
||||
<span class="reappro-crossing-parent-gender" style="color:${genderColor}">${gender} ${qty}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private bindResultsEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Back buttons
|
||||
this.el.querySelectorAll('#appro-back').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.approState.target = '';
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
const qtyInput = this.el.querySelector('#appro-qty') as HTMLInputElement | null;
|
||||
if (qtyInput) {
|
||||
let prevQty = qtyInput.value;
|
||||
qtyInput.addEventListener('focus', () => { prevQty = qtyInput.value; qtyInput.value = ''; });
|
||||
qtyInput.addEventListener('blur', () => {
|
||||
if (qtyInput.value === '') qtyInput.value = prevQty;
|
||||
this.approState.qty = Math.max(1, parseInt(qtyInput.value) || 1);
|
||||
qtyInput.value = String(this.approState.qty);
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
this.el.querySelectorAll('.reappro-repro-input').forEach(inp => {
|
||||
const inpEl = inp as HTMLInputElement;
|
||||
let prev = inpEl.value;
|
||||
inpEl.addEventListener('focus', () => { prev = inpEl.value; inpEl.value = ''; });
|
||||
inpEl.addEventListener('blur', () => {
|
||||
if (inpEl.value === '') inpEl.value = prev;
|
||||
const race = inpEl.dataset.race!;
|
||||
this.approState.repro[race] = Math.max(0, parseInt(inpEl.value) || 0);
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
this.el.querySelectorAll('.reappro-invert-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const race = (btn as HTMLElement).dataset.race!;
|
||||
this.approState.inverted[race] = !this.approState.inverted[race];
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
const saveBtn = this.el.querySelector('#appro-save');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const { target, qty, repro } = this.approState;
|
||||
const { materials, steps } = this.calcAppro(target, qty);
|
||||
this.commandBus.execute({
|
||||
type: 'save-workflow',
|
||||
target,
|
||||
qty,
|
||||
materials,
|
||||
steps,
|
||||
repro: { ...repro },
|
||||
});
|
||||
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">check</span> Sauvegardé !';
|
||||
setTimeout(() => {
|
||||
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">save</span> Sauvegarder ce plan';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Breeding plan calculation ── */
|
||||
private calcAppro(target: string, qty: number): { materials: { race: string; m: number; f: number }[]; steps: ApproStep[] } {
|
||||
const targetGen = RACE_GEN[target] ?? 0;
|
||||
if (targetGen < 2 || !BREEDING_RECIPES[target]) {
|
||||
return { materials: [], steps: [] };
|
||||
}
|
||||
|
||||
const needs: Record<string, ApproNeeds> = {};
|
||||
needs[target] = { total: qty, m: 0, f: 0 };
|
||||
|
||||
const steps: ApproStep[] = [];
|
||||
|
||||
for (let g = targetGen; g >= 2; g--) {
|
||||
const racesAtGen: string[] = [];
|
||||
for (const [rg, rs] of Object.entries(RACES_DATA)) {
|
||||
if (parseInt(rg) === g) {
|
||||
for (const r of rs) {
|
||||
if (needs[r.name]) racesAtGen.push(r.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const race of racesAtGen) {
|
||||
const recipe = BREEDING_RECIPES[race];
|
||||
if (!recipe) continue;
|
||||
const need = needs[race];
|
||||
if (!need || need.total <= 0) continue;
|
||||
|
||||
const reproCount = this.approState.repro[race] ?? 0;
|
||||
const couples = Math.max(1, need.total - reproCount);
|
||||
const isInverted = this.approState.inverted[race] ?? false;
|
||||
|
||||
const [recipeA, recipeB] = recipe;
|
||||
const parentA = isInverted ? recipeB : recipeA;
|
||||
const parentB = isInverted ? recipeA : recipeB;
|
||||
|
||||
if (!needs[parentA]) needs[parentA] = { total: 0, m: 0, f: 0 };
|
||||
needs[parentA].total += couples;
|
||||
needs[parentA].m += couples;
|
||||
|
||||
if (!needs[parentB]) needs[parentB] = { total: 0, m: 0, f: 0 };
|
||||
needs[parentB].total += couples;
|
||||
needs[parentB].f += couples;
|
||||
|
||||
steps.push({ baby: race, parentA, parentB, couples, gen: g });
|
||||
}
|
||||
}
|
||||
|
||||
const materials: { race: string; m: number; f: number }[] = [];
|
||||
for (const [race, need] of Object.entries(needs)) {
|
||||
if (!BREEDING_RECIPES[race] && need.total > 0) {
|
||||
materials.push({ race, m: need.m, f: need.f });
|
||||
}
|
||||
}
|
||||
|
||||
steps.reverse();
|
||||
return { materials, steps };
|
||||
}
|
||||
}
|
||||
133
src/presentation/components/Sidebar.ts
Executable file
133
src/presentation/components/Sidebar.ts
Executable file
@ -0,0 +1,133 @@
|
||||
import type { UIState } from '@presentation/state/UIState';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { DashboardResult } from '@application/queries/GetDashboard';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
|
||||
export class Sidebar {
|
||||
private el: HTMLElement | null = null;
|
||||
private cachedVersion: string | null = null;
|
||||
|
||||
constructor(
|
||||
private uiState: UIState,
|
||||
private queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('aside');
|
||||
this.el.className = 'sidebar-new';
|
||||
container.appendChild(this.el);
|
||||
this.update();
|
||||
this.fetchAndInjectVersion();
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
const data = this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
|
||||
const enclosList = data.enclosSummaries;
|
||||
const activeView = this.uiState.activeView;
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── Header ──
|
||||
html += `
|
||||
<div class="sb-header">
|
||||
<div class="sb-logo-wrap">
|
||||
<img src="icone_sidebar.png" alt="logo" class="sb-logo-img" />
|
||||
</div>
|
||||
<div class="sb-brand">
|
||||
<span class="sb-brand-name">Obsidienne</span>
|
||||
<span class="sb-brand-sub">Gestion d'élevage</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Nav ──
|
||||
html += `<div class="sb-nav">`;
|
||||
|
||||
// Section Principal
|
||||
html += `<div class="sb-section">`;
|
||||
html += `<div class="sb-section-head"><span class="sb-section-label">Principal</span></div>`;
|
||||
html += this.item('dashboard', 'dashboard', 'Tableau de bord', activeView === 'dashboard');
|
||||
html += this.item('statistiques', 'bar_chart', 'Statistiques', activeView === 'statistiques');
|
||||
html += `</div>`;
|
||||
|
||||
// Section Enclos
|
||||
html += `<div class="sb-section">`;
|
||||
html += `<div class="sb-section-head"><span class="sb-section-label">Enclos</span></div>`;
|
||||
enclosList.forEach(enc => {
|
||||
const isActive = activeView === enc.id;
|
||||
const dotClass = enc.running ? 'running' : 'idle';
|
||||
html += `<button class="sb-item${isActive ? ' active' : ''}" data-view="${enc.id}">`;
|
||||
html += `<span class="sb-item-icon material-symbols-outlined">pentagon</span>`;
|
||||
html += `<span class="sb-item-text">${esc(enc.name)}</span>`;
|
||||
html += `<span class="sb-dot ${dotClass}"></span>`;
|
||||
html += `</button>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
// Section Outils
|
||||
html += `<div class="sb-section">`;
|
||||
html += `<div class="sb-section-head"><span class="sb-section-label">Outils</span></div>`;
|
||||
html += this.item('accouplement', 'favorite', 'Accouplement', activeView === 'accouplement');
|
||||
html += this.item('appro', 'science', 'R\u00e9appro', activeView === 'appro');
|
||||
html += this.item('inventaire', 'inventory_2', 'Inventaire', activeView === 'inventaire');
|
||||
html += this.item('workflows', 'account_tree', 'Workflows', activeView === 'workflows');
|
||||
html += `</div>`;
|
||||
|
||||
html += `</div>`; // end sb-nav
|
||||
|
||||
// ── Footer ──
|
||||
html += `
|
||||
<div class="sb-footer">
|
||||
${this.item('parametres', 'settings', 'Param\u00e8tres', activeView === 'parametres')}
|
||||
<div class="sb-version">
|
||||
<span class="material-symbols-outlined">info</span>
|
||||
<span id="sb-ver">v\u2014</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindEvents();
|
||||
this.fetchAndInjectVersion();
|
||||
}
|
||||
|
||||
private item(viewId: string, icon: string, label: string, active: boolean): string {
|
||||
return `<button class="sb-item${active ? ' active' : ''}" data-view="${viewId}">` +
|
||||
`<span class="sb-item-icon material-symbols-outlined">${icon}</span>` +
|
||||
`<span class="sb-item-text">${esc(label)}</span>` +
|
||||
`</button>`;
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
if (!this.el) return;
|
||||
this.el.querySelectorAll<HTMLElement>('.sb-item[data-view]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const view = btn.dataset['view']!;
|
||||
const viewValue: string | number = /^\d+$/.test(view) ? Number(view) : view;
|
||||
this.uiState.setActiveView(viewValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private fetchAndInjectVersion(): void {
|
||||
if (this.cachedVersion) {
|
||||
const verEl = this.el?.querySelector('#sb-ver');
|
||||
if (verEl) verEl.textContent = `v${this.cachedVersion}`;
|
||||
return;
|
||||
}
|
||||
const api = (window as any).electronAPI;
|
||||
if (!api?.getVersion) return;
|
||||
api.getVersion().then((v: string) => {
|
||||
this.cachedVersion = v;
|
||||
const verEl = this.el?.querySelector('#sb-ver');
|
||||
if (verEl) verEl.textContent = `v${v}`;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
509
src/presentation/components/StatistiquesView.ts
Executable file
509
src/presentation/components/StatistiquesView.ts
Executable file
@ -0,0 +1,509 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { StatisticsResult, KpiDelta } from '@application/queries/GetStatistics';
|
||||
import { TOTAL_RACES } from '@application/queries/GetStatistics';
|
||||
import { GEN_COLORS, raceColor } from '@domain/value-objects/Race';
|
||||
import { getDDImage } from '@presentation/helpers/dd-image';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ days: 7, label: '7 jours' },
|
||||
{ days: 14, label: '14 jours' },
|
||||
{ days: 30, label: '30 jours' },
|
||||
{ days: 90, label: '3 mois' },
|
||||
{ days: 0, label: 'Tout' },
|
||||
];
|
||||
|
||||
export class StatistiquesView {
|
||||
private el: HTMLElement | null = null;
|
||||
private selectedDays = 30;
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'stats-view';
|
||||
container.appendChild(this.el);
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
update(): void {}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
private getData(): StatisticsResult {
|
||||
return this.queryBus.execute<StatisticsResult>({ type: 'get-statistics', days: this.selectedDays });
|
||||
}
|
||||
|
||||
private renderAll(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
try {
|
||||
this.renderAllInner();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const stack = e instanceof Error ? e.stack ?? '' : '';
|
||||
console.error('Erreur statistiques:', e);
|
||||
this.el.innerHTML = `<div class="stats-hero">
|
||||
<div>
|
||||
<h2 class="stats-hero-title">Statistiques d'\u00c9levage</h2>
|
||||
<p class="stats-hero-sub" style="color:#f87171">Erreur : ${esc(msg)}</p>
|
||||
<pre style="color:#f87171;font-size:11px;white-space:pre-wrap;max-height:200px;overflow:auto;margin-top:8px">${esc(stack)}</pre>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
private renderAllInner(): void {
|
||||
if (!this.el) return;
|
||||
const data = this.getData();
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── Hero + Filtres ──────────────────────────────────────────
|
||||
html += `<div class="stats-hero">
|
||||
<div>
|
||||
<h2 class="stats-hero-title">Statistiques d'\u00c9levage</h2>
|
||||
<p class="stats-hero-sub">Aperçu analytique des performances de votre archive.</p>
|
||||
</div>
|
||||
<div class="stats-period-chips">`;
|
||||
for (const opt of PERIOD_OPTIONS) {
|
||||
const active = this.selectedDays === opt.days ? ' active' : '';
|
||||
html += `<button class="stats-period-chip${active}" data-days="${opt.days}">${esc(opt.label)}</button>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
|
||||
// ── KPIs ────────────────────────────────────────────────────
|
||||
html += `<div class="stats-kpi-grid">`;
|
||||
html += this.renderKpi('Total Naissances', data.totalBabies, 'egg_alt');
|
||||
html += this.renderKpi('Taux de R\u00e9ussite', data.successRate, 'trending_up', '%');
|
||||
html += this.renderKpi('Couples Form\u00e9s', data.totalCouples, 'favorite');
|
||||
html += this.renderKpiRaces(data.racesCount);
|
||||
html += `</div>`;
|
||||
|
||||
// ── Graphique naissances + Donut ─────────────────────────────
|
||||
html += `<div class="stats-two-col">`;
|
||||
html += this.renderBarChart(data);
|
||||
html += this.renderRaceDistribution(data);
|
||||
html += `</div>`;
|
||||
|
||||
// ── Répartition par génération + Activité semaine ────────────
|
||||
html += `<div class="stats-two-col">`;
|
||||
html += this.renderGenBreakdown(data);
|
||||
html += this.renderWeekdayActivity(data);
|
||||
html += `</div>`;
|
||||
|
||||
// ── Détail par race ──────────────────────────────────────────
|
||||
html += this.renderRaceDetail(data);
|
||||
|
||||
// ── Taux de réussite par race ────────────────────────────────
|
||||
html += this.renderRaceSuccessRates(data);
|
||||
|
||||
// ── Meilleurs couples ────────────────────────────────────────
|
||||
html += this.renderBestCouples(data);
|
||||
|
||||
// ── Races manquantes (toujours en dernier) ───────────────────
|
||||
html += this.renderMissingRaces(data);
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
// ── KPIs ──────────────────────────────────────────────────────
|
||||
|
||||
private renderKpi(label: string, kpi: KpiDelta, icon: string, suffix = ''): string {
|
||||
return `<div class="stats-kpi-card">
|
||||
<div class="stats-kpi-icon-wrap">
|
||||
<span class="material-symbols-outlined stats-kpi-icon">${icon}</span>
|
||||
</div>
|
||||
<p class="stats-kpi-label">${label}</p>
|
||||
<div class="stats-kpi-row">
|
||||
<span class="stats-kpi-value">${kpi.value}${suffix}</span>
|
||||
${this.deltaTag(kpi.delta, suffix)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderKpiRaces(kpi: KpiDelta): string {
|
||||
return `<div class="stats-kpi-card">
|
||||
<div class="stats-kpi-icon-wrap">
|
||||
<span class="material-symbols-outlined stats-kpi-icon">pets</span>
|
||||
</div>
|
||||
<p class="stats-kpi-label">Races Obtenues</p>
|
||||
<div class="stats-kpi-row">
|
||||
<span class="stats-kpi-value">${kpi.value}<span class="stats-kpi-total">/${TOTAL_RACES}</span></span>
|
||||
${this.deltaTag(kpi.delta)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private deltaTag(delta: number | null, suffix = ''): string {
|
||||
if (delta === null) return '';
|
||||
if (delta === 0) return `<span class="stats-delta stats-delta-neutral">= 0${suffix}</span>`;
|
||||
const sign = delta > 0 ? '+' : '';
|
||||
const cls = delta > 0 ? 'stats-delta-up' : 'stats-delta-down';
|
||||
return `<span class="stats-delta ${cls}">${sign}${delta}${suffix}</span>`;
|
||||
}
|
||||
|
||||
// ── Graphique barres ──────────────────────────────────────────
|
||||
|
||||
private renderBarChart(data: StatisticsResult): string {
|
||||
const maxCount = Math.max(...data.dailyBirths.map(d => d.count), 1);
|
||||
const hasData = data.dailyBirths.some(d => d.count > 0);
|
||||
const periodLabel = this.selectedDays === 0 ? '30 DERNIERS JOURS' : `${this.selectedDays} DERNIERS JOURS`;
|
||||
|
||||
let html = `<div class="stats-chart-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">\u00c9volution des Naissances</h3>
|
||||
<span class="stats-chart-badge">${periodLabel}</span>
|
||||
</div>`;
|
||||
|
||||
if (!hasData) {
|
||||
html += `<div class="stats-chart-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">bar_chart</span>
|
||||
<p>Aucune naissance sur cette p\u00e9riode</p>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="stats-chart-area">`;
|
||||
html += `<div class="stats-chart-y-axis">
|
||||
<span>${maxCount}</span>
|
||||
<span>${Math.round(maxCount / 2)}</span>
|
||||
<span>0</span>
|
||||
</div>`;
|
||||
html += `<div class="stats-chart-bars">`;
|
||||
for (const day of data.dailyBirths) {
|
||||
const pct = (day.count / maxCount) * 100;
|
||||
const isToday = day === data.dailyBirths[data.dailyBirths.length - 1];
|
||||
html += `<div class="stats-bar-col">
|
||||
<div class="stats-tooltip">${day.label}<br><strong>${day.count}</strong> naissance${day.count !== 1 ? 's' : ''}</div>
|
||||
<div class="stats-bar${isToday ? ' stats-bar-today' : ''}" style="height:${Math.max(pct, day.count > 0 ? 4 : 0)}%"></div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
const step = Math.max(1, Math.floor(data.dailyBirths.length / 6));
|
||||
html += `<div class="stats-chart-x-axis">`;
|
||||
for (let i = 0; i < data.dailyBirths.length; i++) {
|
||||
if (i % step === 0 || i === data.dailyBirths.length - 1) {
|
||||
html += `<span>${data.dailyBirths[i].label}</span>`;
|
||||
}
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Donut races ───────────────────────────────────────────────
|
||||
|
||||
private renderRaceDistribution(data: StatisticsResult): string {
|
||||
const top = data.raceShares.slice(0, 8);
|
||||
|
||||
let html = `<div class="stats-race-panel">
|
||||
<h3 class="stats-chart-title">R\u00e9partition des Races</h3>`;
|
||||
|
||||
if (top.length === 0) {
|
||||
html += `<div class="stats-chart-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">donut_large</span>
|
||||
<p>Aucune race enregistr\u00e9e</p>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="stats-donut-wrap">
|
||||
<div class="stats-donut-center">
|
||||
<span class="stats-donut-num">${data.racesCount.value}</span>
|
||||
<span class="stats-donut-label">RACES</span>
|
||||
</div>
|
||||
${this.renderDonutSVG(top)}
|
||||
</div>`;
|
||||
html += `<div class="stats-race-legend">`;
|
||||
for (const r of top) {
|
||||
const col = raceColor(r.race);
|
||||
html += `<div class="stats-race-legend-row">
|
||||
<div class="stats-race-legend-left">
|
||||
<span class="stats-race-dot" style="background:${esc(col)}"></span>
|
||||
<span class="stats-race-name">${esc(r.race)}</span>
|
||||
</div>
|
||||
<div class="stats-race-legend-right">
|
||||
<span class="stats-race-count">${r.count}</span>
|
||||
<span class="stats-race-pct">${r.pct}%</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (data.raceShares.length > 8) {
|
||||
const otherCount = data.raceShares.slice(8).reduce((s, r) => s + r.count, 0);
|
||||
const otherPct = data.totalBabies.value > 0 ? Math.round((otherCount / data.totalBabies.value) * 100) : 0;
|
||||
html += `<div class="stats-race-legend-row">
|
||||
<div class="stats-race-legend-left">
|
||||
<span class="stats-race-dot" style="background:var(--md-outline-variant)"></span>
|
||||
<span class="stats-race-name">Autres (${data.raceShares.length - 8})</span>
|
||||
</div>
|
||||
<div class="stats-race-legend-right">
|
||||
<span class="stats-race-count">${otherCount}</span>
|
||||
<span class="stats-race-pct">${otherPct}%</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderDonutSVG(shares: { race: string; pct: number; count: number }[]): string {
|
||||
const r = 60, circ = 2 * Math.PI * r, gap = 2;
|
||||
let offset = 0;
|
||||
let arcs = '';
|
||||
for (const s of shares) {
|
||||
const col = raceColor(s.race);
|
||||
const len = Math.max((s.pct / 100) * circ - gap, 0);
|
||||
arcs += `<circle class="stats-donut-arc" cx="75" cy="75" r="${r}" fill="none"
|
||||
stroke="${col}" stroke-width="14"
|
||||
stroke-dasharray="${len} ${circ - len}" stroke-dashoffset="${-offset}"
|
||||
stroke-linecap="round" transform="rotate(-90 75 75)">
|
||||
<title>${esc(s.race)} — ${s.count} (${s.pct}%)</title>
|
||||
</circle>`;
|
||||
offset += len + gap;
|
||||
}
|
||||
return `<svg class="stats-donut-svg" viewBox="0 0 150 150" width="150" height="150">${arcs}</svg>`;
|
||||
}
|
||||
|
||||
// ── Répartition par génération ─────────────────────────────────
|
||||
|
||||
private renderGenBreakdown(data: StatisticsResult): string {
|
||||
let html = `<div class="stats-chart-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">Naissances par G\u00e9n\u00e9ration</h3>
|
||||
</div>`;
|
||||
|
||||
if (data.genBreakdown.length === 0) {
|
||||
html += `<div class="stats-chart-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">genetics</span>
|
||||
<p>Aucune donn\u00e9e</p>
|
||||
</div>`;
|
||||
} else {
|
||||
const maxBabies = Math.max(...data.genBreakdown.map(g => g.babies), 1);
|
||||
html += `<div class="stats-gen-bars">`;
|
||||
for (const g of data.genBreakdown) {
|
||||
const col = GEN_COLORS[g.gen] ?? '#888';
|
||||
const pct = (g.babies / maxBabies) * 100;
|
||||
const rate = g.couples > 0 ? Math.round((g.babies / g.couples) * 100) : 0;
|
||||
html += `<div class="stats-gen-row">
|
||||
<span class="stats-gen-label" style="color:${esc(col)}">Gen ${g.gen}</span>
|
||||
<div class="stats-gen-bar-wrap">
|
||||
<div class="stats-gen-bar" style="width:${pct}%;background:${esc(col)}"></div>
|
||||
</div>
|
||||
<div class="stats-gen-info">
|
||||
<span class="stats-gen-count">${g.babies}</span>
|
||||
<span class="stats-gen-rate">${rate}%</span>
|
||||
<span class="stats-gen-races">${g.races} race${g.races > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Activité par jour de la semaine ────────────────────────────
|
||||
|
||||
private renderWeekdayActivity(data: StatisticsResult): string {
|
||||
const maxCount = Math.max(...data.weekdayActivity.map(d => d.count), 1);
|
||||
const hasData = data.weekdayActivity.some(d => d.count > 0);
|
||||
|
||||
let html = `<div class="stats-race-panel">
|
||||
<h3 class="stats-chart-title">Activit\u00e9 par Jour</h3>`;
|
||||
|
||||
if (!hasData) {
|
||||
html += `<div class="stats-chart-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">calendar_month</span>
|
||||
<p>Aucune activit\u00e9</p>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="stats-weekday-grid">`;
|
||||
for (const w of data.weekdayActivity) {
|
||||
const pct = (w.count / maxCount) * 100;
|
||||
const intensity = Math.min(pct / 100, 1);
|
||||
const bg = `rgba(157, 120, 255, ${0.1 + intensity * 0.6})`;
|
||||
html += `<div class="stats-weekday-item">
|
||||
<span class="stats-weekday-name">${w.day.slice(0, 3)}</span>
|
||||
<div class="stats-weekday-block" style="background:${bg}" title="${w.day} : ${w.count} naissance${w.count !== 1 ? 's' : ''}">
|
||||
<span class="stats-weekday-count">${w.count}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Taux de réussite par race ──────────────────────────────────
|
||||
|
||||
private renderRaceSuccessRates(data: StatisticsResult): string {
|
||||
if (data.raceSuccessRates.length === 0) return '';
|
||||
|
||||
let html = `<div class="stats-history-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">Taux de R\u00e9ussite par Race</h3>
|
||||
<span class="stats-chart-badge">${data.raceSuccessRates.length} RACES</span>
|
||||
</div>
|
||||
<div class="stats-success-grid">`;
|
||||
|
||||
for (const r of data.raceSuccessRates) {
|
||||
const col = raceColor(r.race);
|
||||
const rateClass = r.rate >= 80 ? 'stats-rate-high' : r.rate >= 50 ? 'stats-rate-mid' : 'stats-rate-low';
|
||||
html += `<div class="stats-success-row">
|
||||
<div class="stats-success-race">
|
||||
<div class="stats-success-avatar">${getDDImage(r.race)}</div>
|
||||
<span class="stats-success-name" style="color:${esc(col)}">${esc(r.race)}</span>
|
||||
</div>
|
||||
<div class="stats-success-bar-wrap">
|
||||
<div class="stats-success-bar" style="width:${r.rate}%;background:${esc(col)}"></div>
|
||||
</div>
|
||||
<span class="stats-success-rate ${rateClass}">${r.rate}%</span>
|
||||
<span class="stats-success-detail">${r.babies}/${r.couples}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Meilleurs couples ──────────────────────────────────────────
|
||||
|
||||
private renderBestCouples(data: StatisticsResult): string {
|
||||
if (data.bestCouples.length === 0) return '';
|
||||
|
||||
let html = `<div class="stats-history-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">Meilleurs Couples</h3>
|
||||
<span class="stats-chart-badge">TOP ${data.bestCouples.length}</span>
|
||||
</div>
|
||||
<div class="stats-couples-grid">`;
|
||||
|
||||
for (let i = 0; i < data.bestCouples.length; i++) {
|
||||
const c = data.bestCouples[i];
|
||||
const babyCol = raceColor(c.baby);
|
||||
const rateClass = c.rate >= 80 ? 'stats-rate-high' : c.rate >= 50 ? 'stats-rate-mid' : 'stats-rate-low';
|
||||
html += `<div class="stats-couple-row">
|
||||
<span class="stats-couple-rank">#${i + 1}</span>
|
||||
<div class="stats-couple-parents">
|
||||
<div class="stats-couple-av">${getDDImage(c.parentA)}</div>
|
||||
<span class="material-symbols-outlined" style="font-size:14px;color:var(--md-outline-variant)">add</span>
|
||||
<div class="stats-couple-av">${getDDImage(c.parentB)}</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined" style="font-size:20px;color:var(--md-primary)">arrow_forward</span>
|
||||
<div class="stats-couple-baby">
|
||||
<div class="stats-couple-baby-av">${getDDImage(c.baby)}</div>
|
||||
<span style="color:${esc(babyCol)};font-weight:600;font-size:13px">${esc(c.baby)}</span>
|
||||
</div>
|
||||
<span class="stats-couple-rate ${rateClass}">${c.rate}%</span>
|
||||
<span class="stats-couple-detail">${c.babies}/${c.couples}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Races manquantes ───────────────────────────────────────────
|
||||
|
||||
private renderMissingRaces(data: StatisticsResult): string {
|
||||
if (data.missingRaces.length === 0) {
|
||||
return `<div class="stats-history-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">Races Manquantes</h3>
|
||||
<span class="stats-chart-badge stats-badge-success">COMPLET !</span>
|
||||
</div>
|
||||
<div class="stats-chart-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:48px;color:#22c55e">check_circle</span>
|
||||
<p style="color:#22c55e;font-weight:600">Toutes les ${TOTAL_RACES} races ont \u00e9t\u00e9 obtenues !</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Grouper par génération
|
||||
const byGen = new Map<number, typeof data.missingRaces>();
|
||||
for (const r of data.missingRaces) {
|
||||
if (!byGen.has(r.gen)) byGen.set(r.gen, []);
|
||||
byGen.get(r.gen)!.push(r);
|
||||
}
|
||||
|
||||
let html = `<div class="stats-history-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">Races Manquantes</h3>
|
||||
<span class="stats-chart-badge">${data.missingRaces.length} / ${TOTAL_RACES} RESTANTES</span>
|
||||
</div>
|
||||
<div class="stats-missing-list">`;
|
||||
|
||||
for (const [gen, races] of Array.from(byGen.entries()).sort((a, b) => a[0] - b[0])) {
|
||||
const genCol = GEN_COLORS[gen] ?? '#888';
|
||||
html += `<div class="stats-missing-gen">
|
||||
<span class="stats-missing-gen-badge" style="background:${esc(genCol)}">Gen ${gen}</span>
|
||||
<div class="stats-missing-races">`;
|
||||
for (const r of races) {
|
||||
const col = raceColor(r.name);
|
||||
html += `<div class="stats-missing-card">
|
||||
<div class="stats-missing-av">${getDDImage(r.name)}</div>
|
||||
<span class="stats-missing-name" style="color:${esc(col)}">${esc(r.name)}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Détail par race (barres horizontales) ──────────────────────
|
||||
|
||||
private renderRaceDetail(data: StatisticsResult): string {
|
||||
if (data.raceShares.length === 0) return '';
|
||||
|
||||
const maxCount = data.raceShares[0].count;
|
||||
|
||||
let html = `<div class="stats-history-panel">
|
||||
<div class="stats-chart-header">
|
||||
<h3 class="stats-chart-title">D\u00e9tail par Race</h3>
|
||||
<span class="stats-chart-badge">${data.raceShares.length} / ${TOTAL_RACES} RACES</span>
|
||||
</div>
|
||||
<div class="stats-race-bars">`;
|
||||
|
||||
for (const r of data.raceShares) {
|
||||
const col = raceColor(r.race);
|
||||
const pct = maxCount > 0 ? (r.count / maxCount) * 100 : 0;
|
||||
html += `<div class="stats-race-bar-row">
|
||||
<span class="stats-race-bar-name" style="color:${esc(col)}">${esc(r.race)}</span>
|
||||
<div class="stats-race-bar-track">
|
||||
<div class="stats-race-bar-fill" style="width:${pct}%;background:${esc(col)}"></div>
|
||||
</div>
|
||||
<span class="stats-race-bar-count">${r.count}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────
|
||||
|
||||
private bindEvents(): void {
|
||||
if (!this.el) return;
|
||||
this.el.querySelectorAll<HTMLElement>('.stats-period-chip').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
this.selectedDays = Number(chip.dataset['days']);
|
||||
this.renderAll();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
98
src/presentation/components/Toast.ts
Executable file
98
src/presentation/components/Toast.ts
Executable file
@ -0,0 +1,98 @@
|
||||
export type ToastType = 'success' | 'error';
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
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 DURATION_WITH_ACTION = 10_000;
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
let nextId = 0;
|
||||
let container: HTMLElement | null = null;
|
||||
const items: ToastItem[] = [];
|
||||
|
||||
export const Toast = {
|
||||
mount(parent: HTMLElement): void {
|
||||
if (container?.isConnected) return; // déjà monté
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
parent.appendChild(container);
|
||||
},
|
||||
|
||||
show(type: ToastType, message: string, action?: ToastAction): void {
|
||||
if (!container?.isConnected) 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);
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'toast-icon material-symbols-outlined';
|
||||
iconSpan.textContent = ICON[type];
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.className = 'toast-msg';
|
||||
msgSpan.textContent = message;
|
||||
el.appendChild(iconSpan);
|
||||
el.appendChild(msgSpan);
|
||||
|
||||
if (action) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'toast-action';
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener('click', () => {
|
||||
action.callback();
|
||||
cleanup();
|
||||
}, { once: true });
|
||||
el.appendChild(btn);
|
||||
}
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => el.classList.add('toast-visible'));
|
||||
|
||||
const cleanup = () => {
|
||||
if (!el.parentNode) return;
|
||||
el.remove();
|
||||
const idx = items.findIndex(i => i.id === id);
|
||||
if (idx !== -1) items.splice(idx, 1);
|
||||
};
|
||||
|
||||
const duration = action ? DURATION_WITH_ACTION : DURATION[type];
|
||||
|
||||
setTimeout(() => {
|
||||
el.classList.remove('toast-visible');
|
||||
el.classList.add('toast-exit');
|
||||
el.addEventListener('animationend', cleanup, { once: true });
|
||||
// Fallback si animationend ne se declenche pas
|
||||
setTimeout(cleanup, 500);
|
||||
}, duration);
|
||||
},
|
||||
};
|
||||
118
src/presentation/components/UpdateBanner.ts
Executable file
118
src/presentation/components/UpdateBanner.ts
Executable file
@ -0,0 +1,118 @@
|
||||
declare const window: Window & {
|
||||
electronAPI?: {
|
||||
onUpdateAvailable?: (cb: (info: { version: string }) => void) => void;
|
||||
onUpdateDownloading?: (cb: (info: any) => void) => void;
|
||||
onUpdateProgress?: (cb: (progress: { percent: number }) => void) => void;
|
||||
onUpdateReady?: (cb: () => void) => void;
|
||||
onUpdateError?: (cb: (err: { message: string }) => void) => void;
|
||||
installUpdate?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export class UpdateBanner {
|
||||
private el: HTMLElement | null = null;
|
||||
private state: 'hidden' | 'available' | 'downloading' | 'ready' | 'error' = 'hidden';
|
||||
private progressPercent = 0;
|
||||
private version = '';
|
||||
private errorMsg = '';
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'update-banner-new';
|
||||
container.appendChild(this.el);
|
||||
|
||||
this.bindElectronEvents();
|
||||
}
|
||||
|
||||
private bindElectronEvents(): void {
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
|
||||
api.onUpdateAvailable?.((info) => {
|
||||
this.state = 'available';
|
||||
this.version = info.version;
|
||||
this.updateDisplay();
|
||||
});
|
||||
|
||||
api.onUpdateDownloading?.(() => {
|
||||
this.state = 'downloading';
|
||||
this.progressPercent = 0;
|
||||
this.updateDisplay();
|
||||
});
|
||||
|
||||
api.onUpdateProgress?.((progress) => {
|
||||
this.state = 'downloading';
|
||||
this.progressPercent = Math.round(progress.percent);
|
||||
this.updateDisplay();
|
||||
});
|
||||
|
||||
api.onUpdateReady?.(() => {
|
||||
this.state = 'ready';
|
||||
// version was set in onUpdateAvailable
|
||||
this.updateDisplay();
|
||||
});
|
||||
|
||||
api.onUpdateError?.((err) => {
|
||||
this.state = 'error';
|
||||
this.errorMsg = err.message;
|
||||
this.updateDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
this.el.className = `update-banner-new ${this.state}`;
|
||||
|
||||
switch (this.state) {
|
||||
case 'hidden':
|
||||
this.el.innerHTML = '';
|
||||
return;
|
||||
|
||||
case 'available':
|
||||
this.el.innerHTML =
|
||||
`<span class="upd-dot"></span>` +
|
||||
`<span class="upd-text">Mise \u00e0 jour disponible\u00a0: <strong>v${this.escHtml(this.version)}</strong> — T\u00e9l\u00e9chargement en cours…</span>`;
|
||||
break;
|
||||
|
||||
case 'downloading':
|
||||
this.el.innerHTML =
|
||||
`<span class="upd-dot"></span>` +
|
||||
`<span class="upd-text">T\u00e9l\u00e9chargement de la mise \u00e0 jour…</span>` +
|
||||
`<div class="upd-bar-wrap"><div class="upd-bar-fill" style="width:${this.progressPercent}%"></div></div>` +
|
||||
`<span class="upd-percent">${this.progressPercent}\u00a0%</span>`;
|
||||
break;
|
||||
|
||||
case 'ready': {
|
||||
this.el.innerHTML =
|
||||
`<span class="upd-dot"></span>` +
|
||||
`<span class="upd-text">Mise \u00e0 jour <strong>v${this.escHtml(this.version)}</strong> pr\u00eate !</span>` +
|
||||
`<button class="upd-install-btn" id="upd-install-btn">Installer et red\u00e9marrer</button>`;
|
||||
const btn = this.el.querySelector('#upd-install-btn') as HTMLElement | null;
|
||||
btn?.addEventListener('click', () => window.electronAPI?.installUpdate?.());
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
this.el.innerHTML =
|
||||
`<span class="upd-dot"></span>` +
|
||||
`<span class="upd-text">Erreur de mise \u00e0 jour\u00a0: ${this.escHtml(this.errorMsg)}</span>` +
|
||||
`<button class="upd-dismiss-btn" id="upd-dismiss-btn">\u2715</button>`;
|
||||
const dismissBtn = this.el.querySelector('#upd-dismiss-btn') as HTMLElement | null;
|
||||
dismissBtn?.addEventListener('click', () => {
|
||||
this.state = 'hidden';
|
||||
this.updateDisplay();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private escHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
697
src/presentation/components/WorkflowsView.ts
Executable file
697
src/presentation/components/WorkflowsView.ts
Executable file
@ -0,0 +1,697 @@
|
||||
import type { CommandBus } from '@application/handlers/CommandBus';
|
||||
import type { QueryBus } from '@application/handlers/QueryBus';
|
||||
import type { UIState } from '@presentation/state/UIState';
|
||||
import type { WorkflowItem } from '@application/queries/GetWorkflows';
|
||||
import type { ImportWorkflowsCommand } from '@application/commands/ImportWorkflows';
|
||||
import { GEN_COLORS, RACE_GEN, raceColor } from '@domain/value-objects/Race';
|
||||
import { getDDImage } from '@presentation/helpers/dd-image';
|
||||
import { esc } from '@presentation/helpers/format';
|
||||
import { Toast } from './Toast';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { UndoManager } from '@presentation/services/UndoManager';
|
||||
|
||||
interface WorkflowProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
function getProgress(wf: WorkflowItem): WorkflowProgress {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
wf.materials.forEach(m => {
|
||||
total += m.needed;
|
||||
done += Math.min(m.done, m.needed);
|
||||
});
|
||||
wf.steps.forEach(st =>
|
||||
st.crossings.forEach(cr => {
|
||||
total += cr.needed;
|
||||
done += Math.min(cr.done, cr.needed);
|
||||
}),
|
||||
);
|
||||
return total > 0 ? { done, total, pct: Math.round((done / total) * 100) } : { done: 0, total: 0, pct: 0 };
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days === 0) return "Aujourd'hui";
|
||||
if (days === 1) return 'Hier';
|
||||
if (days < 7) return `Il y a ${days} jours`;
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
export class WorkflowsView {
|
||||
private el: HTMLElement | null = null;
|
||||
private detailId: number | null = null;
|
||||
private genFilter = 0;
|
||||
private search = '';
|
||||
private dirty = true;
|
||||
private exportMode = false;
|
||||
private selectedIds = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private commandBus: CommandBus,
|
||||
private queryBus: QueryBus,
|
||||
private uiState: UIState,
|
||||
) {}
|
||||
|
||||
render(container: HTMLElement): void {
|
||||
this.el = document.createElement('div');
|
||||
this.el.className = 'wf-view-new';
|
||||
container.appendChild(this.el);
|
||||
this.updateDOM();
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (!this.dirty) return;
|
||||
this.dirty = false;
|
||||
this.updateDOM();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
|
||||
private getWorkflows(): WorkflowItem[] {
|
||||
return this.queryBus.execute<WorkflowItem[]>({ type: 'get-workflows' });
|
||||
}
|
||||
|
||||
private updateDOM(): void {
|
||||
if (!this.el) return;
|
||||
if (this.detailId !== null) {
|
||||
this.renderDetail(this.detailId);
|
||||
} else {
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
// ── List view ──────────────────────────────────────────────────
|
||||
|
||||
private renderList(): void {
|
||||
if (!this.el) return;
|
||||
const allWorkflows = this.getWorkflows();
|
||||
|
||||
const q = this.search.trim().toLowerCase();
|
||||
const filtered = allWorkflows.filter(wf => {
|
||||
const gen = RACE_GEN[wf.target] ?? 0;
|
||||
const matchGen = this.genFilter === 0 || gen === this.genFilter;
|
||||
const matchSearch = !q || wf.target.toLowerCase().includes(q) || wf.name.toLowerCase().includes(q);
|
||||
return matchGen && matchSearch;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
/* Header */
|
||||
if (this.exportMode) {
|
||||
const allSelected = filtered.length > 0 && filtered.every(wf => this.selectedIds.has(wf.id));
|
||||
html += `<div class="wf-export-bar">
|
||||
<div class="wf-export-bar-left">
|
||||
<button class="wf-io-btn" id="wf-export-cancel">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">close</span>
|
||||
Annuler
|
||||
</button>
|
||||
<label class="wf-select-all-label">
|
||||
<input type="checkbox" id="wf-select-all" ${allSelected ? 'checked' : ''}>
|
||||
<span>Tout sélectionner</span>
|
||||
</label>
|
||||
<span class="wf-export-count">${this.selectedIds.size} sélectionné${this.selectedIds.size > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<button class="wf-io-btn wf-export-confirm" id="wf-export-confirm"${this.selectedIds.size === 0 ? ' disabled' : ''}>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">download</span>
|
||||
Exporter (${this.selectedIds.size})
|
||||
</button>
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="wf-list-header">
|
||||
<div>
|
||||
<h2 class="wf-list-title">Sommaire des Plans</h2>
|
||||
<p class="wf-list-subtitle">Gérez et suivez l'évolution de vos plans de reproduction.</p>
|
||||
</div>
|
||||
<div class="wf-header-actions">
|
||||
<button class="wf-io-btn" id="wf-import-btn" title="Importer des plans">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">upload</span>
|
||||
Importer
|
||||
</button>
|
||||
<button class="wf-io-btn" id="wf-export-btn" title="Exporter les plans"${allWorkflows.length === 0 ? ' disabled' : ''}>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">download</span>
|
||||
Exporter
|
||||
</button>
|
||||
<span class="wf-list-count">${allWorkflows.length} plan${allWorkflows.length > 1 ? 's' : ''} actif${allWorkflows.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* Search + Gen chips */
|
||||
html += `<div class="inv-filters-row">
|
||||
<div class="inv-search-col">
|
||||
<div class="accoup-search-wrap">
|
||||
<input class="accoup-search" id="wf-search-input" type="text"
|
||||
placeholder="Rechercher un plan…" value="${esc(this.search)}" autocomplete="off">
|
||||
${this.search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="inv-gen-col">
|
||||
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
|
||||
<button class="accoup-gen-chip${this.genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
|
||||
for (let g = 1; g <= 10; g++) {
|
||||
const hasWf = allWorkflows.some(wf => (RACE_GEN[wf.target] ?? 0) === g);
|
||||
if (!hasWf) continue;
|
||||
html += `<button class="accoup-gen-chip${this.genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
|
||||
}
|
||||
html += `</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* Cards grid */
|
||||
if (allWorkflows.length === 0) {
|
||||
html += `<div class="wf-empty">
|
||||
<span class="material-symbols-outlined" style="font-size:48px;color:var(--md-outline-variant)">assignment</span>
|
||||
<p>Aucun plan sauvegardé</p>
|
||||
<p style="font-size:12px;color:var(--md-on-surface-variant)">Créez un plan depuis l'onglet Appro ou Inventaire.</p>
|
||||
</div>`;
|
||||
} else if (filtered.length === 0) {
|
||||
html += `<div class="accoup-empty">Aucun plan trouvé</div>`;
|
||||
} else {
|
||||
html += `<div class="wf-cards-grid">`;
|
||||
for (const wf of filtered) {
|
||||
html += this.renderWorkflowCard(wf);
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindListEvents();
|
||||
|
||||
if (this.search) {
|
||||
const inp = this.el.querySelector<HTMLInputElement>('#wf-search-input');
|
||||
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
||||
}
|
||||
}
|
||||
|
||||
private renderWorkflowCard(wf: WorkflowItem): string {
|
||||
const prog = getProgress(wf);
|
||||
const gen = RACE_GEN[wf.target] ?? 0;
|
||||
const genColor = GEN_COLORS[gen] ?? '#888';
|
||||
const col = raceColor(wf.target);
|
||||
const isSelected = this.selectedIds.has(wf.id);
|
||||
|
||||
return `<div class="wf-card${this.exportMode ? ' wf-card-selectable' : ''}${isSelected ? ' wf-card-selected' : ''}" data-wf-id="${wf.id}">
|
||||
${this.exportMode ? `<div class="wf-card-checkbox"><input type="checkbox" class="wf-card-check" data-wf-id="${wf.id}" ${isSelected ? 'checked' : ''}></div>` : ''}
|
||||
<div class="wf-card-top">
|
||||
<div class="wf-card-meta">
|
||||
<span class="wf-card-gen" style="color:${esc(genColor)}">Génération ${gen}</span>
|
||||
<h4 class="wf-card-name">${esc(wf.name)}</h4>
|
||||
</div>
|
||||
<div class="wf-card-avatar">
|
||||
${getDDImage(wf.target)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wf-card-progress">
|
||||
<div class="wf-card-progress-row">
|
||||
<span class="wf-card-progress-label">Progression Actuelle</span>
|
||||
<span class="wf-card-progress-pct" style="color:${esc(col)}">${prog.pct}%</span>
|
||||
</div>
|
||||
<div class="wf-card-bar">
|
||||
<div class="wf-card-bar-fill" style="width:${prog.pct}%;background:${esc(col)}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wf-card-footer">
|
||||
<div class="wf-card-date">
|
||||
<span class="material-symbols-outlined" style="font-size:14px">history</span>
|
||||
Dernière modif : ${formatDate(wf.updatedAt ?? wf.createdAt)}
|
||||
</div>
|
||||
<div class="wf-card-actions">
|
||||
<button class="wf-delete-btn" data-wf-id="${wf.id}" title="Supprimer">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">delete</span>
|
||||
</button>
|
||||
<div class="wf-card-arrow">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">arrow_forward</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private bindListEvents(): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Search
|
||||
const searchInput = this.el.querySelector<HTMLInputElement>('#wf-search-input');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => { this.search = searchInput.value; this.dirty = true; this.update(); });
|
||||
}
|
||||
const clearBtn = this.el.querySelector('.accoup-search-clear');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => { this.search = ''; this.dirty = true; this.update(); });
|
||||
}
|
||||
|
||||
// Gen filter chips
|
||||
this.el.querySelectorAll('.accoup-gen-chip[data-gen]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
if (this.exportMode) {
|
||||
// Export mode events
|
||||
|
||||
// Cancel export mode
|
||||
const cancelBtn = this.el.querySelector('#wf-export-cancel');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.exportMode = false;
|
||||
this.selectedIds.clear();
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
// Select all
|
||||
const selectAll = this.el.querySelector<HTMLInputElement>('#wf-select-all');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', () => {
|
||||
const allWorkflows = this.getWorkflows();
|
||||
// Apply to filtered workflows only
|
||||
const q = this.search.trim().toLowerCase();
|
||||
const filtered = allWorkflows.filter(wf => {
|
||||
const gen = RACE_GEN[wf.target] ?? 0;
|
||||
const matchGen = this.genFilter === 0 || gen === this.genFilter;
|
||||
const matchSearch = !q || wf.target.toLowerCase().includes(q) || wf.name.toLowerCase().includes(q);
|
||||
return matchGen && matchSearch;
|
||||
});
|
||||
if (selectAll.checked) {
|
||||
filtered.forEach(wf => this.selectedIds.add(wf.id));
|
||||
} else {
|
||||
filtered.forEach(wf => this.selectedIds.delete(wf.id));
|
||||
}
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkboxes (click anywhere on card toggles)
|
||||
this.el.querySelectorAll('.wf-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const id = Number((card as HTMLElement).dataset['wfId']);
|
||||
if (this.selectedIds.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
} else {
|
||||
this.selectedIds.add(id);
|
||||
}
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm export
|
||||
const confirmBtn = this.el.querySelector('#wf-export-confirm');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.addEventListener('click', () => this.exportWorkflows());
|
||||
}
|
||||
|
||||
} else {
|
||||
// Normal mode events
|
||||
|
||||
// Card click → open detail
|
||||
this.el.querySelectorAll('.wf-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.wf-delete-btn')) return;
|
||||
const id = Number((card as HTMLElement).dataset['wfId']);
|
||||
this.detailId = id;
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
// Export button → enter export mode
|
||||
const exportBtn = this.el.querySelector('#wf-export-btn');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
this.exportMode = true;
|
||||
this.selectedIds.clear();
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
// Import button
|
||||
const importBtn = this.el.querySelector('#wf-import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', () => this.importWorkflows());
|
||||
}
|
||||
|
||||
// Delete buttons
|
||||
this.el.querySelectorAll('.wf-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = Number((btn as HTMLElement).dataset['wfId']);
|
||||
const workflows = this.getWorkflows();
|
||||
const wf = workflows.find(w => w.id === id);
|
||||
const name = wf ? wf.name : String(id);
|
||||
const ok = await ConfirmModal.show('Supprimer le plan', `Supprimer "${name}" ? Cette action est irréversible.`);
|
||||
if (!ok) return;
|
||||
const hasSnap = await UndoManager.snapshotCurrent('Suppression plan');
|
||||
this.commandBus.execute({ type: 'delete-workflow', workflowId: id });
|
||||
Toast.show('success', `Plan "${name}" supprimé.`, hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detail view ───────────────────────────────────────────────
|
||||
|
||||
private renderDetail(id: number): void {
|
||||
if (!this.el) return;
|
||||
const workflows = this.getWorkflows();
|
||||
const wf = workflows.find(w => w.id === id);
|
||||
|
||||
if (!wf) {
|
||||
this.detailId = null;
|
||||
this.dirty = true; this.update();
|
||||
return;
|
||||
}
|
||||
|
||||
const prog = getProgress(wf);
|
||||
const gen = RACE_GEN[wf.target] ?? 0;
|
||||
const genColor = GEN_COLORS[gen] ?? '#888';
|
||||
const col = raceColor(wf.target);
|
||||
|
||||
let html = '';
|
||||
|
||||
/* Top bar: back + objective + progress */
|
||||
html += `<div class="wf-detail-top">
|
||||
<button class="reappro-back-btn" id="wf-back-btn">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
|
||||
Retour au planneur
|
||||
</button>
|
||||
<div class="wf-detail-objective">
|
||||
<span class="material-symbols-outlined" style="font-size:18px;color:var(--md-primary)">timeline</span>
|
||||
<span class="wf-detail-obj-text">Objectif : ${esc(wf.target)}</span>
|
||||
</div>
|
||||
<div class="wf-detail-progress-wrap">
|
||||
<div class="wf-detail-progress-info">
|
||||
<span class="wf-detail-progress-label">Progression Globale</span>
|
||||
<span class="wf-detail-progress-pct" id="wf-global-pct">${prog.pct}%</span>
|
||||
</div>
|
||||
<div class="wf-detail-progress-bar">
|
||||
<div class="wf-detail-progress-fill" id="wf-global-bar" style="width:${prog.pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* Overview card */
|
||||
html += `<div class="wf-overview-card">
|
||||
<div class="wf-overview-left">
|
||||
<div class="wf-overview-avatar">
|
||||
${getDDImage(wf.target)}
|
||||
<span class="wf-overview-gen-badge" style="background:${esc(genColor)}">GEN ${gen}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="wf-overview-name">${wf.qty}x ${esc(wf.target)}</h3>
|
||||
<p class="wf-overview-meta">Créé le ${new Date(wf.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wf-overview-stats">
|
||||
<div class="wf-overview-stat">
|
||||
<span class="wf-overview-stat-label">Naissances</span>
|
||||
<span class="wf-overview-stat-value" id="wf-stat-births">${prog.done} / ${prog.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
/* Materials */
|
||||
if (wf.materials.length > 0) {
|
||||
const totalSteps = wf.steps.length + 1;
|
||||
html += `<div class="wf-detail-step-header">
|
||||
<span class="wf-detail-step-badge" style="background:var(--md-primary)">Étape 1/${totalSteps}</span>
|
||||
<span class="wf-detail-step-label">Géniteurs de Base (Matières Premières)</span>
|
||||
</div>`;
|
||||
|
||||
html += `<div class="wf-materials-grid">`;
|
||||
wf.materials.forEach((mat, mIdx) => {
|
||||
const matDone = Math.min(mat.done, mat.needed);
|
||||
const matPct = mat.needed > 0 ? Math.round((matDone / mat.needed) * 100) : 0;
|
||||
const matComplete = matDone >= mat.needed;
|
||||
html += `<div class="wf-material-card${matComplete ? ' wf-material-complete' : ''}">
|
||||
<div class="wf-material-avatar">
|
||||
${getDDImage(mat.name)}
|
||||
</div>
|
||||
<div class="wf-material-info">
|
||||
<div class="wf-material-name-row">
|
||||
<p class="wf-material-name">${esc(mat.name)}</p>
|
||||
${matComplete ? '<span class="material-symbols-outlined" style="font-size:16px;color:#22c55e">check_circle</span>' : ''}
|
||||
</div>
|
||||
<div class="wf-material-progress-row">
|
||||
<span class="wf-material-need">Besoin: ${mat.needed}</span>
|
||||
<div class="wf-material-bar-wrap">
|
||||
<span class="wf-material-count" id="wf-mat-count-${mIdx}">${matDone} / ${mat.needed}</span>
|
||||
<div class="wf-material-bar">
|
||||
<div class="wf-material-bar-fill" id="wf-mat-bar-${mIdx}" style="width:${matPct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="number" class="wf-mat-input" data-mat-idx="${mIdx}" data-max="${mat.needed}" min="0" max="${mat.needed}" value="${matDone}">
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
/* Connector */
|
||||
html += `<div class="wf-step-connector"></div>`;
|
||||
}
|
||||
|
||||
/* Step cards (crossings by gen) */
|
||||
const totalSteps = wf.steps.length + (wf.materials.length > 0 ? 1 : 0);
|
||||
wf.steps.forEach((step, sIdx) => {
|
||||
const stepNum = sIdx + (wf.materials.length > 0 ? 2 : 1);
|
||||
const stepGenColor = GEN_COLORS[step.gen] ?? '#888';
|
||||
|
||||
html += `<div class="wf-detail-step-header">
|
||||
<span class="wf-detail-step-badge" style="background:${esc(stepGenColor)}">Étape ${stepNum}/${totalSteps}</span>
|
||||
<span class="wf-detail-step-label">Croisements — Génération ${step.gen}</span>
|
||||
</div>`;
|
||||
|
||||
html += `<div class="wf-crossings-list">`;
|
||||
step.crossings.forEach((cr, cIdx) => {
|
||||
const crDone = Math.min(cr.done, cr.needed);
|
||||
const crPct = cr.needed > 0 ? Math.round((crDone / cr.needed) * 100) : 0;
|
||||
const crCol = raceColor(cr.race);
|
||||
const babyGen = RACE_GEN[cr.race] ?? 0;
|
||||
const babyGenCol = GEN_COLORS[babyGen] ?? '#888';
|
||||
|
||||
html += `<div class="wf-crossing-card">
|
||||
<div class="wf-crossing-row">
|
||||
<div class="wf-crossing-parents">
|
||||
<div class="wf-crossing-parent-av">
|
||||
${getDDImage(cr.parentA)}
|
||||
</div>
|
||||
<span class="material-symbols-outlined" style="font-size:16px;color:var(--md-outline-variant)">add</span>
|
||||
<div class="wf-crossing-parent-av">
|
||||
${getDDImage(cr.parentB)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-symbols-outlined" style="font-size:28px;color:var(--md-primary)">double_arrow</span>
|
||||
<div class="wf-crossing-result">
|
||||
<div class="wf-crossing-baby-av">
|
||||
${getDDImage(cr.race)}
|
||||
<span class="reappro-baby-gen-badge" style="background:${babyGenCol}">G${babyGen}</span>
|
||||
</div>
|
||||
<div class="wf-crossing-result-info">
|
||||
<p class="wf-crossing-baby-name">${esc(cr.race)}</p>
|
||||
<p class="wf-crossing-baby-obj">Objectif : ${cr.needed} réussi${cr.needed > 1 ? 's' : ''}</p>
|
||||
<div class="wf-crossing-dots">
|
||||
${Array.from({ length: cr.needed }, (_, i) =>
|
||||
`<span class="wf-dot${i < crDone ? ' wf-dot-done' : ''}" style="${i < crDone ? `background:${esc(crCol)};box-shadow:0 0 8px ${esc(crCol)}60` : ''}"></span>`
|
||||
).join('')}
|
||||
<span class="wf-crossing-count" id="wf-cr-count-${sIdx}-${cIdx}" style="color:${esc(crCol)}">${crDone} / ${cr.needed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="number" class="wf-crossing-input" data-step-idx="${sIdx}" data-crossing-idx="${cIdx}" data-max="${cr.needed}" min="0" max="${cr.needed}" value="${crDone}">
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
/* Connector between steps */
|
||||
if (sIdx < wf.steps.length - 1) {
|
||||
html += `<div class="wf-step-connector"></div>`;
|
||||
}
|
||||
});
|
||||
|
||||
this.el.innerHTML = html;
|
||||
this.bindDetailEvents(id);
|
||||
}
|
||||
|
||||
private bindDetailEvents(wfId: number): void {
|
||||
if (!this.el) return;
|
||||
|
||||
// Back button
|
||||
const backBtn = this.el.querySelector('#wf-back-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
this.detailId = null;
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
}
|
||||
|
||||
// Material inputs
|
||||
this.el.querySelectorAll<HTMLInputElement>('.wf-mat-input').forEach(input => {
|
||||
let prev = input.value;
|
||||
input.addEventListener('focus', () => { prev = input.value; input.value = ''; });
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value === '') return;
|
||||
const mIdx = Number(input.dataset['matIdx']);
|
||||
const max = Number(input.dataset['max']) || 0;
|
||||
const val = Math.min(Math.max(0, Number(input.value) || 0), max);
|
||||
const pct = max > 0 ? Math.round((val / max) * 100) : 0;
|
||||
const bar = this.el?.querySelector<HTMLElement>(`#wf-mat-bar-${mIdx}`);
|
||||
const cnt = this.el?.querySelector<HTMLElement>(`#wf-mat-count-${mIdx}`);
|
||||
if (bar) bar.style.width = `${pct}%`;
|
||||
if (cnt) cnt.textContent = `${val} / ${max}`;
|
||||
this.refreshGlobalBar();
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value === '') { input.value = prev; this.refreshGlobalBar(); return; }
|
||||
const mIdx = Number(input.dataset['matIdx']);
|
||||
const done = Math.max(0, Number(input.value) || 0);
|
||||
this.commandBus.execute({ type: 'update-workflow', workflowId: wfId, materialIdx: mIdx, done });
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
|
||||
// Crossing inputs
|
||||
this.el.querySelectorAll<HTMLInputElement>('.wf-crossing-input').forEach(input => {
|
||||
let prev = input.value;
|
||||
input.addEventListener('focus', () => { prev = input.value; input.value = ''; });
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value === '') return;
|
||||
const sIdx = Number(input.dataset['stepIdx']);
|
||||
const cIdx = Number(input.dataset['crossingIdx']);
|
||||
const max = Number(input.dataset['max']) || 0;
|
||||
const val = Math.min(Math.max(0, Number(input.value) || 0), max);
|
||||
const cnt = this.el?.querySelector<HTMLElement>(`#wf-cr-count-${sIdx}-${cIdx}`);
|
||||
if (cnt) cnt.textContent = `${val} / ${max}`;
|
||||
this.refreshGlobalBar();
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
if (input.value === '') { input.value = prev; this.refreshGlobalBar(); return; }
|
||||
const sIdx = Number(input.dataset['stepIdx']);
|
||||
const cIdx = Number(input.dataset['crossingIdx']);
|
||||
const done = Math.max(0, Number(input.value) || 0);
|
||||
this.commandBus.execute({ type: 'update-workflow', workflowId: wfId, stepIdx: sIdx, crossingIdx: cIdx, done });
|
||||
this.dirty = true; this.update();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Export / Import ─────────────────────────────────────────
|
||||
|
||||
private async exportWorkflows(): Promise<void> {
|
||||
const allWorkflows = this.getWorkflows();
|
||||
const toExport = allWorkflows.filter(wf => this.selectedIds.has(wf.id));
|
||||
if (toExport.length === 0) return;
|
||||
|
||||
const data = JSON.stringify(toExport, null, 2);
|
||||
const suffix = toExport.length === 1 ? toExport[0].target.toLowerCase().replace(/\s+/g, '-') : 'tous';
|
||||
const defaultName = `plans-dragodinde-${suffix}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
try {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.exportFile) {
|
||||
const ok = await api.exportFile(data, defaultName);
|
||||
if (ok) { Toast.show('success', 'Plans exportés avec succès.'); this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update(); }
|
||||
} else {
|
||||
// Fallback navigateur : téléchargement via Blob
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = defaultName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.show('success', 'Plans exportés avec succès.');
|
||||
this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update();
|
||||
}
|
||||
} catch {
|
||||
Toast.show('error', 'Erreur lors de l\'export des plans.');
|
||||
}
|
||||
}
|
||||
|
||||
private async importWorkflows(): Promise<void> {
|
||||
const api = (window as any).electronAPI;
|
||||
let raw: string | null = null;
|
||||
|
||||
try {
|
||||
if (api?.importFile) {
|
||||
raw = await api.importFile();
|
||||
} else {
|
||||
// Fallback navigateur : input file
|
||||
raw = await new Promise<string | null>(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) { resolve(null); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (!raw) return;
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
|
||||
// Validation basique de la structure
|
||||
const valid = arr.filter((item: any) =>
|
||||
item && typeof item === 'object' &&
|
||||
typeof item.target === 'string' &&
|
||||
typeof item.qty === 'number' &&
|
||||
Array.isArray(item.materials) &&
|
||||
Array.isArray(item.steps),
|
||||
) as WorkflowItem[];
|
||||
|
||||
if (valid.length === 0) {
|
||||
Toast.show('error', 'Aucun plan valide trouvé dans le fichier.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.commandBus.execute({
|
||||
type: 'import-workflows',
|
||||
workflows: valid,
|
||||
} as ImportWorkflowsCommand);
|
||||
|
||||
Toast.show('success', `${valid.length} plan(s) importé(s) avec succès.`);
|
||||
this.dirty = true;
|
||||
this.update();
|
||||
} catch {
|
||||
Toast.show('error', 'Erreur lors de l\'import. Vérifiez que le fichier est un JSON valide.');
|
||||
}
|
||||
}
|
||||
|
||||
private refreshGlobalBar(): void {
|
||||
if (!this.el) return;
|
||||
let done = 0, total = 0;
|
||||
this.el.querySelectorAll<HTMLInputElement>('.wf-mat-input, .wf-crossing-input').forEach(inp => {
|
||||
const max = Number(inp.dataset['max']) || 0;
|
||||
const val = Math.min(Math.max(0, Number(inp.value) || 0), max);
|
||||
total += max;
|
||||
done += val;
|
||||
});
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
const bar = this.el.querySelector<HTMLElement>('#wf-global-bar');
|
||||
const pctEl = this.el.querySelector<HTMLElement>('#wf-global-pct');
|
||||
const births = this.el.querySelector<HTMLElement>('#wf-stat-births');
|
||||
if (bar) bar.style.width = `${pct}%`;
|
||||
if (pctEl) pctEl.textContent = `${pct}%`;
|
||||
if (births) births.textContent = `${done} / ${total}`;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user