From d9f1d7cbb7fff28607aedb1b41d46ef70f23afb1 Mon Sep 17 00:00:00 2001 From: mickael pol Date: Thu, 26 Mar 2026 20:18:55 +0100 Subject: [PATCH] v1.1.4 - ajout de nouvelles features et correction de bugs. --- README.md | 18 + algorithmes.md | 255 +++++++++++ findings.md | 28 ++ main.js | 5 +- package.json | 2 +- progress.md | 12 + src/index.html | 1111 ++++++++++++++++++++++++++++++++++++++++++++++-- task_plan.md | 114 +++++ 8 files changed, 1497 insertions(+), 48 deletions(-) create mode 100644 algorithmes.md create mode 100644 findings.md create mode 100644 progress.md create mode 100644 task_plan.md diff --git a/README.md b/README.md index a063a8d..d93aab9 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,24 @@ dd-timer/ ## Changelog +### v1.1.4 +- ✨ **Cible serenite par DD** — champ cible avec ETA en temps reel (calcul automatique baffeur/caresseur necessaire) +- ✨ **Cible niveau par DD** — champ cible avec ETA en temps reel (modele XP lineaire base sur le tier de la mangeoire) +- ✨ **Inventaire ♂/♀** — saisie du nombre de males et femelles par race dans l'inventaire +- ✨ **Contraintes de genre** — le calcul d'inventaire respecte la regle ♂+♀ obligatoire (plus de croisement ♂+♂ ou ♀+♀) +- ✨ **Bouton "Reinitialiser l'inventaire"** — remet a zero tous les stocks de DD (avec confirmation native) +- ✨ **Affichage du sexe des parents** — les resultats de croisement indiquent quel parent est ♂ et lequel est ♀ +- 🎨 Serenite et XP reintegrees dans la barre de pills (comme endurance, amour, etc.) +- 🎨 Lignes dediees cible + ETA pour serenite et niveau sous les pills +- 🎨 Badges compacts pour les noms de jauges longs (baffeur, caresseur, mangeoire) sur les cartes DD +- 🎨 Zone de clic elargie sur les inputs ♂/♀ de l'inventaire +- 🎨 Padding et espacement corriges sur les cartes inventaire +- 🔧 Correction timer XP incorrect — affichait ~23h au lieu du temps reel +- 🔧 Correction defocus input reproducteur — l'input perdait le focus car oninput declenchait un rebuild DOM +- 🔧 Correction bebes produits — les bebes issus de croisements sont maintenant neutres (sexe inconnu) au lieu d'etre assignes ♂/♀ arbitrairement +- ⚡ Cache DOM `gel()` — les ~500+ appels getElementById par tick passent par un cache Map +- ⚡ Verification de mise a jour uniquement au demarrage (suppression du check toutes les heures) + ### v1.1.3 - ✨ **Reinitialisation des statistiques** — bouton dans l'onglet Statistiques pour remettre a zero tous les bebes, historiques et stats archivees (avec confirmation) - ✨ **Inputs intelligents** — les champs numeriques se vident au clic pour saisir facilement, et restaurent la valeur precedente si rien n'est change diff --git a/algorithmes.md b/algorithmes.md new file mode 100644 index 0000000..1ecb532 --- /dev/null +++ b/algorithmes.md @@ -0,0 +1,255 @@ +# 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. diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..c6071f5 --- /dev/null +++ b/findings.md @@ -0,0 +1,28 @@ +# 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 diff --git a/main.js b/main.js index f02833b..1d5e417 100644 --- a/main.js +++ b/main.js @@ -407,12 +407,9 @@ app.whenReady().then(() => { createWindow(); createTray(); - // Vérifier les mises à jour au démarrage (silencieux) + // Vérifier les mises à jour uniquement au démarrage (silencieux) setTimeout(() => checkForUpdates(true), 3000); - // Revérifier toutes les heures - setInterval(() => checkForUpdates(true), 60 * 60 * 1000); - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/package.json b/package.json index a4f28c4..0d71465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minuteur-dragodinde", - "version": "1.1.3", + "version": "1.1.4", "description": "Minuteur elevage Dragodinde Dofus 3", "main": "main.js", "author": "Mickael", diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..57405d4 --- /dev/null +++ b/progress.md @@ -0,0 +1,12 @@ +# 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 diff --git a/src/index.html b/src/index.html index e783e4f..4819cc5 100644 --- a/src/index.html +++ b/src/index.html @@ -255,6 +255,26 @@ input::-webkit-input-placeholder{color:var(--muted)} .stats-pct.low{color:var(--warn)} .stats-pct.bad{color:var(--amour)} .stats-empty{text-align:center;padding:30px;color:var(--muted);font-style:italic} +/* APPRO / INVENTAIRE */ +.appro-step{background:var(--bg3);border-radius:9px;padding:14px;margin-bottom:10px} +.appro-repro{display:flex;align-items:center;gap:8px;margin-top:8px;padding-top:8px;border-top:1px solid var(--border)} +.inv-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px} +.inv-item{background:var(--bg3);border-radius:8px;padding:10px;display:flex;align-items:center;gap:8px} +.inv-item label{flex:1;font-size:0.85rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.inv-item input{width:55px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:6px 8px;font:700 0.9rem 'Nunito',sans-serif;text-align:center} +.inv-item input:focus{outline:none;border-color:var(--ser)} +.inv-card{display:flex;flex-direction:column;align-items:center;background:var(--bg3);border:2px solid transparent;border-radius:12px;padding:12px 14px 16px;text-align:center;transition:.15s;position:relative} +.inv-card-name{font-size:0.78rem;font-weight:700;line-height:1.15;color:var(--text);height:2.3em;display:flex;align-items:center;justify-content:center;margin-top:5px} +.inv-gender{display:flex;gap:6px;margin-top:auto;padding-top:4px;justify-content:center} +.inv-gender-box{display:flex;align-items:center;gap:2px;padding:4px 8px;border-radius:8px;font-size:0.8rem;font-weight:800;justify-content:center;cursor:text} +.inv-gender-box.male{background:rgba(80,160,255,.1);border:1.5px solid rgba(80,160,255,.3);color:#50a0ff} +.inv-gender-box.female{background:rgba(255,100,160,.1);border:1.5px solid rgba(255,100,160,.3);color:#ff64a0} +.inv-gender-box input{width:34px;background:transparent;border:none;color:inherit;font:800 0.85rem 'Nunito',sans-serif;text-align:center;padding:4px 2px;cursor:text} +.inv-gender-box input:focus{outline:none;background:rgba(255,255,255,.08);border-radius:4px} +.appro-card{background:var(--bg3)!important;color:var(--text)!important;padding:10px!important;cursor:pointer;border:3px solid transparent;border-radius:12px;text-align:center;transition:.15s;position:relative} +.appro-card:hover{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.25)} +.appro-card.selected{box-shadow:0 0 0 3px rgba(192,96,255,.6),0 8px 24px rgba(0,0,0,.35)!important;transform:translateY(-3px)} +.appro-card .race-card-name{color:var(--text)!important} /* SOUS-ONGLETS */ .subtab{padding:11px 16px;border:none;background:transparent;color:var(--muted);cursor:pointer;font:700 0.9rem 'Nunito',sans-serif;transition:.15s;border-bottom:2px solid transparent} .subtab:hover{color:var(--text);background:var(--bg3)} @@ -271,6 +291,25 @@ input::-webkit-input-placeholder{color:var(--muted)} .pill-delta{position:absolute;top:-20px;left:50%;transform:translateX(-50%);font-size:0.72rem;font-weight:800;pointer-events:none;opacity:0;white-space:nowrap;z-index:10} @keyframes pillDeltaUp{0%{opacity:1;transform:translateX(-50%) translateY(0)}100%{opacity:0;transform:translateX(-50%) translateY(-16px)}} .pill-delta.show{animation:pillDeltaUp .9s ease-out forwards} +/* SÉRÉNITÉ TARGET ROW */ +.seren-row{display:flex;align-items:center;gap:6px;padding:6px 10px;margin:-4px 0 8px;border-radius:10px;background:rgba(192,96,255,.06);border:1px solid rgba(192,96,255,.18)} +.seren-row .seren-icon{font-size:1rem;flex-shrink:0} +.seren-row .seren-lbl{font-size:0.75rem;color:rgba(192,96,255,.7);font-weight:700;white-space:nowrap} +.seren-row input{background:rgba(192,96,255,.08);border:1.5px solid rgba(192,96,255,.3);color:rgb(192,96,255);font:800 0.82rem 'Nunito',sans-serif;width:58px;text-align:center;padding:3px 4px;border-radius:8px;transition:border-color .2s} +.seren-row input:focus{outline:none;border-color:rgb(192,96,255);background:rgba(192,96,255,.14)} +.seren-row .seren-arrow{color:rgba(192,96,255,.5);font-size:0.9rem;font-weight:800} +.seren-row .seren-eta{font-size:0.78rem;font-weight:800;color:rgb(192,96,255);white-space:nowrap;margin-left:auto} +.seren-row .seren-eta .eta-need,.level-row .level-eta .eta-need{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:8px;font-size:0.7rem;opacity:.7} +.seren-row .eta-need{background:rgba(192,96,255,.12);border:1px solid rgba(192,96,255,.25)} +.level-row .eta-need{background:rgba(255,160,64,.12);border:1px solid rgba(255,160,64,.25)} +/* LEVEL TARGET ROW */ +.level-row{display:flex;align-items:center;gap:6px;padding:6px 10px;margin:-4px 0 8px;border-radius:10px;background:rgba(255,160,64,.06);border:1px solid rgba(255,160,64,.18)} +.level-row .level-icon{font-size:1rem;flex-shrink:0} +.level-row .level-lbl{font-size:0.75rem;color:rgba(255,160,64,.7);font-weight:700;white-space:nowrap} +.level-row input{background:rgba(255,160,64,.08);border:1.5px solid rgba(255,160,64,.3);color:rgb(255,160,64);font:800 0.82rem 'Nunito',sans-serif;width:52px;text-align:center;padding:3px 4px;border-radius:8px;transition:border-color .2s} +.level-row input:focus{outline:none;border-color:rgb(255,160,64);background:rgba(255,160,64,.14)} +.level-row .level-arrow{color:rgba(255,160,64,.5);font-size:0.9rem;font-weight:800} +.level-row .level-eta{font-size:0.78rem;font-weight:800;color:rgb(255,160,64);white-space:nowrap;margin-left:auto} @@ -456,7 +495,7 @@ function getDDImage(raceName) { // ══════════════════════════════════════════ // DONNÉES RACES DRAGODINDES // ══════════════════════════════════════════ -const GEN_COLORS={2:'#4caf50',3:'#ff9800',4:'#e91e63',5:'#00bcd4',6:'#9c27b0',7:'#ffc107',8:'#43a047',9:'#f44336',10:'#66bb6a'}; +const GEN_COLORS={1:'#c8622a',2:'#e8b820',3:'#6040b0',4:'#2a8acc',5:'#c03050',6:'#d040a0',7:'#c8c0a0',8:'#20a8b0',9:'#28a058',10:'#8050a0'}; const RACE_BASE_COLORS={ 'Rousse':'#c8622a','Amande':'#d4b48a','Dorée':'#e8b820', @@ -568,8 +607,81 @@ const RACES_DATA={ ], }; +// ══════════════════════════════════════════ +// ARBRE DE REPRODUCTION (recettes) +// ══════════════════════════════════════════ +const BREEDING_RECIPES={ + 'Dorée et Rousse':['Rousse','Dorée'], + 'Amande et Dorée':['Amande','Dorée'], + 'Amande et Rousse':['Amande','Rousse'], + 'Ebène':['Amande et Rousse','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'], +}; +const RACE_GEN={}; +['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))); -let S={enclos:[],activeId:null,nextEnclosId:1,alarmSound:'arpege',notifsEnabled:true,ntfyTopic:'',archivedStats:[]}; +let approState={target:'',qty:1,repro:{}}; + +let S={enclos:[],activeId:null,nextEnclosId:1,alarmSound:'arpege',notifsEnabled:true,ntfyTopic:'',archivedStats:[],inventaire:{},workflows:[]}; const NTFY_BASE='https://ntfy.mickael-pol.fr'; const NTFY_REDIRECT='https://ntfy-redirect.mickael-pol.fr'; const lastTickIdx={}; @@ -583,7 +695,8 @@ function makeEnclos(id){ timer:{running:false,startTime:null,pausedAt:null,pausedMs:0,snapGauges:{},snapStats:{}}, alerted:{}}; } -const activeEnclos=()=>(S.activeId==='dashboard'||S.activeId==='stats')?null:S.enclos.find(e=>e.id===S.activeId)||S.enclos[0]; +const SPECIAL_TABS=['dashboard','stats','appro','inventaire','workflows']; +const activeEnclos=()=>SPECIAL_TABS.includes(S.activeId)?null:S.enclos.find(e=>e.id===S.activeId)||S.enclos[0]; // ══════════════════════════════════════════ // MATHS @@ -631,7 +744,13 @@ function computeGaugeLive(enc,dd,gid,el,started){ const startGl=started?(enc.timer.snapGauges[gid]??enc.gaugeLevels[gid]):enc.gaugeLevels[gid]; const curGl=started?Math.max(0,gaugeAfter(startGl,el)):startGl; const startSt=started?(enc.timer.snapStats[dd.id]?.[def.stat]??dd.stats[def.stat]):dd.stats[def.stat]; - const target=dd.targets[gid]; + // Utiliser levelTarget/sereniteTarget s'ils sont définis (priorité sur les cibles jauges) + let target=dd.targets[gid]; + if(def.isXp&&dd.levelTarget!=null) target=dd.levelTarget; + if(def.stat==='serenite'&&dd.sereniteTarget!=null){ + if(def.dir<0) target=Math.min(target,dd.sereniteTarget); // baffeur: cible basse + else target=Math.max(target,dd.sereniteTarget); // caresseur: cible haute + } const curRate=tierRate(curGl); if(def.isXp){ @@ -1015,6 +1134,106 @@ function applySuggest(encId,ddId){ save();updateLive(); } +// ── SÉRÉNITÉ CIBLE ──────────────────────────────────────────────── +function updateSereniteTarget(encId,ddId,v){ + const enc=S.enclos.find(e=>e.id===encId); + const dd=enc?.dragodindes.find(d=>d.id===ddId); + if(!dd)return; + const val=v===''||v===null?null:Math.min(5000,Math.max(-5000,parseInt(v)||0)); + dd.sereniteTarget=val; + // Sync les cibles des jauges baffeur/caresseur pour que le countdown global soit cohérent + if(val!==null){ + if(val0; // need caresseur (dir +1) + const needDown=diff<0; // need baffeur (dir -1) + const gid=needUp?'caresseur':'baffeur'; + if(!enc.activeGauges.includes(gid))return needUp?'':''; + const gl=enc.gaugeLevels[gid]||0; + const pts=Math.abs(diff); + const sec=timeToGain(gl,pts); + return sec===Infinity?'∞':'~'+fmt(sec); +} +function calcSerenEtaLive(enc,dd,el,started){ + const target=dd.sereniteTarget; + if(target===null||target===undefined)return'—'; + // Find the current estimated serenite from active gauges + let estSer=dd.stats.serenite; + if(started){ + // Check each serenity-affecting gauge for live estimate + ['baffeur','caresseur'].forEach(gid=>{ + if(!enc.activeGauges.includes(gid))return; + const r=computeGaugeLive(enc,dd,gid,el,started); + estSer=Math.round(r.estStat); + }); + } + const diff=target-estSer; + if(diff===0||(diff>0&&estSer>=target)||(diff<0&&estSer<=target))return'✅'; + const needUp=diff>0; + const gid=needUp?'caresseur':'baffeur'; + if(!enc.activeGauges.includes(gid))return needUp?'':''; + const gl=started?gaugeAfter(enc.timer.snapGauges?.[gid]??enc.gaugeLevels[gid],el):enc.gaugeLevels[gid]; + const pts=Math.abs(diff); + const sec=timeToGain(gl,pts); + return sec===Infinity?'∞':'~'+fmt(sec); +} + +// ── NIVEAU CIBLE ────────────────────────────────────────────────── +function updateLevelTarget(encId,ddId,v){ + const enc=S.enclos.find(e=>e.id===encId); + const dd=enc?.dragodindes.find(d=>d.id===ddId); + if(!dd)return; + const val=v===''||v===null?null:Math.min(200,Math.max(1,parseInt(v)||1)); + dd.levelTarget=val; + // Sync la cible de la jauge mangeoire pour que le countdown global soit cohérent + dd.targets.mangeoire=val??100; + save();updateLive(); +} +function calcLevelEta(enc,dd){ + const target=dd.levelTarget; + if(target===null||target===undefined)return'—'; + const cur=dd.stats.xp; + if(cur>=target)return'✅'; + if(!enc.activeGauges.includes('mangeoire'))return'🍖'; + const gl=enc.gaugeLevels.mangeoire||0; + const xpNeeded=Math.max(0,xpForLevel(target)-xpForLevel(cur)); + if(xpNeeded<=0)return'✅'; + // Taux fixe basé sur le tier de la jauge (le joueur recharge la mangeoire) + const rate=Math.max(10,tierRate(gl)); + const sec=Math.ceil(xpNeeded/rate)*10; + return'~'+fmt(sec); +} +function calcLevelEtaLive(enc,dd,el,started){ + const target=dd.levelTarget; + if(target===null||target===undefined)return'—'; + let estLvl=dd.stats.xp; + if(started&&enc.activeGauges.includes('mangeoire')){ + const r=computeGaugeLive(enc,dd,'mangeoire',el,started); + estLvl=r.estStat; // levelFromXp déjà appliqué dans computeGaugeLive pour isXp + } + if(estLvl>=target)return'✅'; + if(!enc.activeGauges.includes('mangeoire'))return'🍖'; + // XP restante depuis le level estimé actuel jusqu'au level cible + const xpNow=xpForLevel(estLvl); + const xpTarget=xpForLevel(target); + const xpLeft=Math.max(0,xpTarget-xpNow); + if(xpLeft<=0)return'✅'; + const gl=started?(enc.timer.snapGauges?.mangeoire??enc.gaugeLevels.mangeoire):enc.gaugeLevels.mangeoire; + const rate=Math.max(10,tierRate(gl)); + const sec=Math.ceil(xpLeft/rate)*10; + return'~'+fmt(sec); +} + // ══════════════════════════════════════════ // TIMER // ══════════════════════════════════════════ @@ -1089,6 +1308,8 @@ async function load(){ S.notifsEnabled=d.notifsEnabled!==undefined?d.notifsEnabled:true; S.ntfyTopic=d.ntfyTopic||''; S.archivedStats=d.archivedStats||[]; + S.inventaire=d.inventaire||{}; + S.workflows=d.workflows||[]; // Migration: ancien format ntfyUrl → ntfyTopic if(!S.ntfyTopic&&d.ntfyUrl){const m=d.ntfyUrl.match(/\/([^\/]+)$/);if(m)S.ntfyTopic=m[1];} S.enclos.forEach(enc=>{ @@ -1135,6 +1356,28 @@ function renderTabs(){ stab.onclick=()=>selectEnclos('stats'); row.appendChild(stab); + // Appro tab + const atab=document.createElement('div'); + atab.className='tab'+(S.activeId==='appro'?' active':''); + atab.innerHTML='🔄 Réappro'; + atab.onclick=()=>selectEnclos('appro'); + row.appendChild(atab); + + // Inventaire tab + const itab=document.createElement('div'); + itab.className='tab'+(S.activeId==='inventaire'?' active':''); + itab.innerHTML='📦 Inventaire'; + itab.onclick=()=>selectEnclos('inventaire'); + row.appendChild(itab); + + // Workflows tab + const wtab=document.createElement('div'); + wtab.className='tab'+(S.activeId==='workflows'?' active':''); + const wcount=S.workflows.length; + wtab.innerHTML=`📋 Workflows${wcount?` (${wcount})`:''}`; + wtab.onclick=()=>selectEnclos('workflows'); + row.appendChild(wtab); + // Enclos tabs — tout le tab est cliquable, drag & drop pour réordonner S.enclos.forEach(enc=>{ const gs=enclosGlobalState(enc); @@ -1220,8 +1463,12 @@ function renderTabs(){ // RENDU CONTENU // ══════════════════════════════════════════ function renderContent(){ + clearElCache(); if(S.activeId==='dashboard'){renderDashboard();return;} if(S.activeId==='stats'){renderStats();return;} + if(S.activeId==='appro'){renderApprovisionnement();return;} + if(S.activeId==='inventaire'){renderInventaire();return;} + if(S.activeId==='workflows'){renderWorkflows();return;} const enc=activeEnclos(); if(!enc){document.getElementById('enclos-content').innerHTML='';return;} const c=document.getElementById('enclos-content'); @@ -1402,7 +1649,7 @@ function renderDDs(enc){ sh+=`
`; PILL_DEFS.forEach(p=>{ const val=dd.stats[p.stat]; - const atMax=(p.stat==='serenite')?val<=-5000:val>=p.max; + const atMax=(p.stat==='serenite')?val<=-5000:(p.stat==='xp')?val>=200:val>=p.max; const bgStyle=atMax?`background:rgba(${p.color},.22);box-shadow:0 0 8px rgba(${p.color},.35)`:'background:transparent'; sh+=`
@@ -1417,6 +1664,32 @@ function renderDDs(enc){
`; }); sh+=`
`; + // ── SÉRÉNITÉ : cible + ETA ────────────────────────── + const serTarget=dd.sereniteTarget??null; + sh+=`
+ 😊 + Cible + + ${serTarget!==null?calcSerenEta(enc,dd):'—'} +
`; + // ── NIVEAU : cible + ETA ────────────────────────── + const lvlTarget=dd.levelTarget??null; + sh+=`
+ + Cible + + ${lvlTarget!==null?calcLevelEta(enc,dd):'—'} +
`; // ── JAUGES ACTIVES (contrôles actuel→cible) ──────────────────── if(enc.activeGauges.length){ enc.activeGauges.forEach(gid=>{ @@ -1451,7 +1724,7 @@ function renderDDs(enc){ updateLive(); } -function render(){renderTabs();renderContent();} +function render(){clearElCache();renderTabs();renderContent();} // ══════════════════════════════════════════ // DASHBOARD @@ -1519,23 +1792,22 @@ function updateDashboardLive(){ const cdColor=gs.allDone&&gs.started?'var(--ok)':enc.timer.running&&gs.globalMax<300?'var(--warn)':'var(--muted)'; const cdText=gs.allDone&&gs.started?'✅ Terminé !':gs.started?fmtClock(gs.globalMax):'—'; - const dot=document.getElementById(`dash-dot-${enc.id}`); + const dot=gel(`dash-dot-${enc.id}`); if(dot){dot.style.background=statusColor;} - const stxt=document.getElementById(`dash-status-txt-${enc.id}`); + const stxt=gel(`dash-status-txt-${enc.id}`); if(stxt){stxt.textContent=statusText;stxt.style.color=statusColor;} - const cdEl=document.getElementById(`dash-cd-${enc.id}`); + const cdEl=gel(`dash-cd-${enc.id}`); if(cdEl){cdEl.textContent=cdText;cdEl.style.color=cdColor;} - const elEl=document.getElementById(`dash-elapsed-${enc.id}`); + const elEl=gel(`dash-elapsed-${enc.id}`); if(elEl)elEl.textContent=fmtClock(el); - const ddEl=document.getElementById(`dash-dd-${enc.id}`); + const ddEl=gel(`dash-dd-${enc.id}`); if(ddEl){ const doneCount=gs.started&&enc.activeGauges.length?gs.ddDone:0; ddEl.textContent=gs.started&&enc.activeGauges.length ?`${enc.dragodindes.length} DD · ${doneCount}/${enc.dragodindes.length} ✅` :`${enc.dragodindes.length} DD`; } - // Card border - const card=document.getElementById(`dash-enc-${enc.id}`); + const card=gel(`dash-enc-${enc.id}`); if(card){ card.className='dash-card'+(enc.timer.running?' running':'')+(gs.allDone&&gs.started?' done-enc':''); } @@ -1551,6 +1823,7 @@ function updateLive(){ // 2. Vue active if(S.activeId==='dashboard'){updateDashboardLive();updateTabDots();return;} + if(S.activeId==='appro'||S.activeId==='inventaire'||S.activeId==='workflows'){updateTabDots();return;} const enc=activeEnclos(); if(!enc)return; @@ -1558,7 +1831,7 @@ function updateLive(){ const curTickIdx=Math.floor(el/10); // Horloge - const elEl=document.getElementById(`elapsed-${enc.id}`); + const elEl=gel(`elapsed-${enc.id}`); if(elEl)elEl.textContent=fmtClock(el); // Jauges enclos live @@ -1567,15 +1840,14 @@ function updateLive(){ const startLvl=enc.timer.snapGauges[gid]??enc.gaugeLevels[gid]; const curLvl=Math.max(0,gaugeAfter(startLvl,el)); const pct=(curLvl/100000)*100; - const bar=document.getElementById(`gbar-${enc.id}-${gid}`); + const bar=gel(`gbar-${enc.id}-${gid}`); if(bar)bar.style.width=pct.toFixed(1)+'%'; - // ── TIER BADGE live (fix: mise à jour basée sur le niveau actuel de la jauge) - const badge=document.getElementById(`tbadge-${enc.id}-${gid}`); + const badge=gel(`tbadge-${enc.id}-${gid}`); if(badge){ const newTxt=`Tier ${tierNum(curLvl)} · \xb1${tierRate(curLvl)}/tick`; if(badge.textContent!==newTxt)badge.textContent=newTxt; } - const info=document.getElementById(`ginfo-${enc.id}-${gid}`); + const info=gel(`ginfo-${enc.id}-${gid}`); const color=`var(${GAUGES[gid].cv})`; if(info)info.innerHTML=`Jauge: ${Math.round(curLvl).toLocaleString('fr')} · Vide en ${fmt(timeToGain(curLvl,curLvl))}`; }); @@ -1584,9 +1856,10 @@ function updateLive(){ // DD live let globalMax=0,globalMaxTotal=0; enc.dragodindes.forEach(dd=>{ - const card=document.getElementById(`ddc-${enc.id}-${dd.id}`); + const card=gel(`ddc-${enc.id}-${dd.id}`); if(!card)return; let allDone=enc.activeGauges.length>0; + const eid=enc.id,did=dd.id; enc.activeGauges.forEach(gid=>{ const def=GAUGES[gid],color=`var(${def.cv})`; @@ -1596,7 +1869,7 @@ function updateLive(){ if(!started&&r.totalSec>globalMaxTotal)globalMaxTotal=r.totalSec; // Valeur live + animation tick - const liveEl=document.getElementById(`slv-${enc.id}-${dd.id}-${gid}`); + const liveEl=gel(`slv-${eid}-${did}-${gid}`); if(liveEl){ if(liveEl.textContent.trim()!==r.liveText){ liveEl.textContent=r.liveText; @@ -1607,44 +1880,41 @@ function updateLive(){ // Mettre à jour la pill d'affichage + style at-max if(started){ - const pill=document.getElementById(`pstat-${enc.id}-${dd.id}-${GAUGES[gid].stat}`); + const statKey=def.stat; + const pill=gel(`pstat-${eid}-${did}-${statKey}`); if(pill&&document.activeElement!==pill){ pill.value=Math.round(r.estStat); } - // Sync input "actuel" de la jauge (si pas focus) - const siEl=document.getElementById(`si-${enc.id}-${dd.id}-${gid}`); + const siEl=gel(`si-${eid}-${did}-${gid}`); if(siEl&&document.activeElement!==siEl){ siEl.value=Math.round(r.estStat); } - // Style at-max sur la pill wrapper - const pillWrap=document.getElementById(`pill-wrap-${enc.id}-${dd.id}-${GAUGES[gid].stat}`); + const pillWrap=gel(`pill-wrap-${eid}-${did}-${statKey}`); if(pillWrap){ - const sd=STATS[GAUGES[gid].stat]; - const atMax=GAUGES[gid].dir<0?r.estStat<=sd.min:r.estStat>=sd.max; + const sd=STATS[statKey]; + const atMax=def.dir<0?r.estStat<=sd.min:r.estStat>=sd.max; pillWrap.classList.toggle('at-max',atMax); const col=pillWrap.style.borderColor.replace('rgba(','').replace(')','').split(',').slice(0,3).join(','); pillWrap.style.background=atMax?`rgba(${col},.22)`:'transparent'; - if(atMax)pillWrap.style.boxShadow=`0 0 8px rgba(${col},.35)`; - else pillWrap.style.boxShadow=''; + pillWrap.style.boxShadow=atMax?`0 0 8px rgba(${col},.35)`:''; } } - // ── DELTA live — masqué si done - const tk=`${enc.id}_${dd.id}_${gid}`; + // ── DELTA live + const tk=`${eid}_${did}_${gid}`; const prevTk=lastTickIdx[tk]??-1; - const deltaEl=document.getElementById(`sdelta-${enc.id}-${dd.id}-${gid}`); + const deltaEl=gel(`sdelta-${eid}-${did}-${gid}`); if(deltaEl){ if(started&&!r.done&&curTickIdx>prevTk&&curTickIdx>0){ lastTickIdx[tk]=curTickIdx; deltaEl.textContent=r.deltaText; deltaEl.classList.add('show'); setTimeout(()=>deltaEl.classList.remove('show'),900); - // Animation delta sur la pill correspondante - const pillDelta=document.getElementById(`pill-delta-${enc.id}-${dd.id}-${GAUGES[gid].stat}`); + const pillDelta=gel(`pill-delta-${eid}-${did}-${def.stat}`); if(pillDelta){ pillDelta.textContent=r.deltaText; pillDelta.classList.remove('show'); - void pillDelta.offsetWidth; // reflow + void pillDelta.offsetWidth; pillDelta.classList.add('show'); } }else if(r.done||!started){ @@ -1654,33 +1924,41 @@ function updateLive(){ } // Barre - const barEl=document.getElementById(`spb-${enc.id}-${dd.id}-${gid}`); + const barEl=gel(`spb-${eid}-${did}-${gid}`); if(barEl){barEl.style.width=r.progPct.toFixed(1)+'%';barEl.style.background=r.done?'var(--ok)':color+'99';} // Countdown individuel - const cdEl=document.getElementById(`scd-${enc.id}-${dd.id}-${gid}`); + const cdEl=gel(`scd-${eid}-${did}-${gid}`); if(cdEl){ if(r.done){cdEl.style.color='var(--ok)';cdEl.textContent='✅';} else if(!started){cdEl.style.color=color;cdEl.textContent=`~${fmt(r.totalSec)}`;} else{cdEl.style.color=r.cntDown<300?'var(--warn)':color;cdEl.textContent=fmt(r.cntDown);} } - // Sync dd.stats avec estStat clampé → pills à jour - if(started&&!GAUGES[gid].isXp){ - dd.stats[GAUGES[gid].stat]=Math.round(r.estStat); - }else if(started&&GAUGES[gid].isXp){ + // Sync dd.stats avec estStat clampé + if(started&&!def.isXp){ + dd.stats[def.stat]=Math.round(r.estStat); + }else if(started&&def.isXp){ dd.stats.xp=r.estStat; } }); if(enc.activeGauges.length>0)card.classList.toggle('done',allDone&&started); + + // ── SÉRÉNITÉ live update ── + const serEtaEl=gel(`ser-eta-${eid}-${did}`); + if(serEtaEl)serEtaEl.innerHTML=calcSerenEtaLive(enc,dd,el,started); + + // ── NIVEAU live update ── + const lvlEtaEl=gel(`lvl-eta-${eid}-${did}`); + if(lvlEtaEl)lvlEtaEl.innerHTML=calcLevelEtaLive(enc,dd,el,started); }); // Mettre à jour l'état du bouton +bébé updateBabyBtnState(enc); // Countdown global - const gcdEl=document.getElementById(`gcd-${enc.id}`); + const gcdEl=gel(`gcd-${enc.id}`); const gs=enclosGlobalState(enc); if(gcdEl){ if(!started||!enc.dragodindes.length||!enc.activeGauges.length){ @@ -1701,7 +1979,7 @@ function updateLive(){ // → évite de fermer le select son ou de perdre le focus function updateTabDots(){ S.enclos.forEach(enc=>{ - const tab=document.getElementById(`tab-enc-${enc.id}`); + const tab=gel(`tab-enc-${enc.id}`); if(!tab)return; const gs=enclosGlobalState(enc); tab.className='tab'+(enc.id===S.activeId?' active':'')+(enc.timer.running?' running':'')+(gs.allDone&&gs.started?' done-tab':''); @@ -2143,6 +2421,749 @@ function renderStats(){ } +// ══════════════════════════════════════════ +// RÉAPPROVISIONNEMENT +// ══════════════════════════════════════════ +let approGenFilter=0; // 0 = toutes +function renderApprovisionnement(){ + const c=document.getElementById('enclos-content'); + c.removeAttribute('data-enc'); + + // Onglets de filtrage par gen + let genTabs=`
+ `; + for(let g=2;g<=10;g++){ + if(!RACES_DATA[g])continue; + const gc=GEN_COLORS[g]; + const isActive=approGenFilter===g; + genTabs+=``; + } + genTabs+='
'; + + // Grille unique filtrée + let cardsHtml=''; + for(let g=2;g<=10;g++){ + if(!RACES_DATA[g])continue; + if(approGenFilter!==0&&approGenFilter!==g)continue; + const gc=GEN_COLORS[g]; + RACES_DATA[g].forEach(r=>{ + const sel=approState.target===r.name; + cardsHtml+=`
+
Gen ${g}
+ ${getDDImage(r.name)} +
${r.name}
+
`; + }); + } + + c.innerHTML=` +
+ Réapprovisionnement — Calculateur de croisements +
+
+
Sélectionne ta cible
+ ${genTabs} +
+ ${cardsHtml} +
+
+ ${approState.target?`
+
Quantité souhaitée
+
+
+ ${getDDImage(approState.target)} + ${approState.target} +
+ × + +
+
`:''} +
`; + if(approState.target)calcAppro(); +} + +function selectApproTarget(name){ + if(approState.target===name){approState.target='';approState.repro={};} + else{approState.target=name;approState.repro={};} + renderApprovisionnement(); +} + +function setApproRepro(race,checked){ + approState.repro[race]=checked?Math.max(1,approState.repro[race]||1):0; + calcAppro(); +} +function setApproReproCount(race,val){ + approState.repro[race]=Math.max(1,parseInt(val)||1); + // Pas de calcAppro() ici — le recalcul se fait au onblur pour ne pas détruire l'input actif +} + +function calcAppro(){ + const div=document.getElementById('appro-results'); + if(!div)return; + if(!approState.target||!BREEDING_RECIPES[approState.target]){div.innerHTML='';return;} + + const needs={}; + needs[approState.target]=approState.qty; + const steps=[]; + const processed=new Set(); + const targetGen=RACE_GEN[approState.target]||2; + + for(let gen=targetGen;gen>=2;gen--){ + const racesAtGen=Object.keys(needs).filter(r=>!processed.has(r)&&RACE_GEN[r]===gen&&BREEDING_RECIPES[r]); + for(const race of racesAtGen){ + const Q=needs[race]; + const R=approState.repro[race]||0; + const couples=(2*R>=Q)?Math.ceil(Q/2):Math.max(1,Q-R); + // Si Q=0 on skip + if(Q<=0){processed.add(race);continue;} + const couplesReal=(2*R>=Q)?Math.ceil(Q/2):(R>0?Q-R:Q); + const[a,b]=BREEDING_RECIPES[race]; + needs[a]=(needs[a]||0)+couplesReal; + needs[b]=(needs[b]||0)+couplesReal; + steps.push({race,gen,qty:Q,couples:couplesReal,parentA:a,parentB:b,repro:R}); + processed.add(race); + } + } + + const gen1Needs=Object.entries(needs).filter(([n])=>!BREEDING_RECIPES[n]&&needs[n]>0).sort((a,b)=>b[1]-a[1]); + const totalGen1=gen1Needs.reduce((s,[,q])=>s+q,0); + + const stepsByGen={}; + steps.forEach(s=>{if(!stepsByGen[s.gen])stepsByGen[s.gen]=[];stepsByGen[s.gen].push(s);}); + const gens=Object.keys(stepsByGen).sort((a,b)=>a-b); + + let html=''; + let stepNum=1; + const totalSteps=gens.length+(gen1Needs.length>0?1:0); + + // Helper : mini-carte DD + function miniCard(name,qty,gen){ + const gc=GEN_COLORS[gen||RACE_GEN[name]]||'#888'; + return`
+
${getDDImage(name)} + Gen ${gen||RACE_GEN[name]} +
+ ${name} + ×${qty} +
`; + } + + // Étape 1 : Matières premières Gen 1 + if(gen1Needs.length>0){ + const gc1=GEN_COLORS[1]; + html+=`
+
+ Étape ${stepNum}/${totalSteps} + 📦 Matières premières — Génération 1 +
+
`; + gen1Needs.forEach(([name,qty])=>{html+=miniCard(name,qty,1);}); + html+=`
+
+ Total : ${totalGen1} dragodindes Gen 1 +
`; + // Flèche entre les étapes + html+=`
`; + stepNum++; + } + + // Étapes croisements par génération croissante + gens.forEach((gen,idx)=>{ + const gc=GEN_COLORS[gen]||'#888'; + const isLast=idx===gens.length-1; + html+=`
+
+ Étape ${stepNum}/${totalSteps} + ${isLast?'🎯 ':''}Croisements — Génération ${gen} +
`; + stepsByGen[gen].forEach(s=>{ + const col=raceColor(s.race); + html+=`
+
+ ${miniCard(s.parentA,s.couples,RACE_GEN[s.parentA])} + + + ${miniCard(s.parentB,s.couples,RACE_GEN[s.parentB])} + + ${miniCard(s.race,s.qty,parseInt(gen))} +
+
+ + ${s.repro>0?` + couple${s.repro>1?'s':''}`:''} +
+
`; + }); + html+='
'; + if(!isLast)html+=`
`; + stepNum++; + }); + + // Bouton sauvegarder + html+=`
+ +
`; + + div.innerHTML=html; +} + +function saveWorkflow(){ + if(!approState.target)return; + // Recalculer le plan pour le sauvegarder + const needs={}; + needs[approState.target]=approState.qty; + const steps=[]; + const processed=new Set(); + const targetGen=RACE_GEN[approState.target]||2; + for(let gen=targetGen;gen>=2;gen--){ + const racesAtGen=Object.keys(needs).filter(r=>!processed.has(r)&&RACE_GEN[r]===gen&&BREEDING_RECIPES[r]); + for(const race of racesAtGen){ + const Q=needs[race];if(Q<=0){processed.add(race);continue;} + const R=approState.repro[race]||0; + const couples=(2*R>=Q)?Math.ceil(Q/2):(R>0?Q-R:Q); + const[a,b]=BREEDING_RECIPES[race]; + needs[a]=(needs[a]||0)+couples; + needs[b]=(needs[b]||0)+couples; + steps.push({race,gen,qty:Q,couples,parentA:a,parentB:b,repro:R}); + processed.add(race); + } + } + const materials=Object.entries(needs).filter(([n])=>!BREEDING_RECIPES[n]&&needs[n]>0) + .map(([name,needed])=>({name,needed,done:0})); + const stepsByGen={}; + steps.forEach(s=>{if(!stepsByGen[s.gen])stepsByGen[s.gen]=[];stepsByGen[s.gen].push(s);}); + const planSteps=Object.keys(stepsByGen).sort((a,b)=>a-b).map(gen=>({ + gen:parseInt(gen), + crossings:stepsByGen[gen].map(s=>({race:s.race,needed:s.qty,parentA:s.parentA,parentB:s.parentB,couples:s.couples,repro:s.repro,done:0})) + })); + + const wf={ + id:Date.now(), + name:`${approState.qty}× ${approState.target}`, + target:approState.target, + qty:approState.qty, + repro:{...approState.repro}, + createdAt:Date.now(), + materials, + steps:planSteps + }; + S.workflows.push(wf); + save(); + selectEnclos('workflows'); +} + +function getWorkflowProgress(wf){ + let done=0,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}; +} + +// ══════════════════════════════════════════ +// WORKFLOWS +// ══════════════════════════════════════════ +let activeWorkflowId=null; + +function renderWorkflows(){ + const c=document.getElementById('enclos-content'); + c.removeAttribute('data-enc'); + + if(activeWorkflowId){ + const wf=S.workflows.find(w=>w.id===activeWorkflowId); + if(wf){renderWorkflowDetail(wf);return;} + activeWorkflowId=null; + } + + if(S.workflows.length===0){ + c.innerHTML=` +
+ Workflows sauvegardés +
+
Aucun workflow.
Utilise l'onglet Réappro pour calculer un plan, puis clique sur 💾 Sauvegarder.
`; + return; + } + + let listHtml=''; + S.workflows.forEach(wf=>{ + const p=getWorkflowProgress(wf); + const gc=GEN_COLORS[RACE_GEN[wf.target]]||'#888'; + const pctColor=p.pct>=100?'var(--ok)':p.pct>=50?'var(--warn)':'var(--amour)'; + const dateStr=new Date(wf.createdAt).toLocaleDateString('fr-FR',{day:'numeric',month:'short',year:'numeric'}); + listHtml+=`
+
+ ${getDDImage(wf.target)} + Gen ${RACE_GEN[wf.target]} +
+
+
${esc(wf.name)}
+
${dateStr}
+
+
+
+
+
+
${p.pct}%
+
${p.done}/${p.total}
+
+ +
`; + }); + + c.innerHTML=` +
+ Workflows sauvegardés +
+
${listHtml}
`; +} + +async function deleteWorkflow(id){ + const wf=S.workflows.find(w=>w.id===id); + if(!wf)return; + const ok=await window.electronAPI.showConfirm('Supprimer le workflow',`Supprimer "${wf.name}" ?`,'La progression sera perdue.'); + if(!ok)return; + S.workflows=S.workflows.filter(w=>w.id!==id); + if(activeWorkflowId===id)activeWorkflowId=null; + save();renderTabs();renderWorkflows(); +} + +function renderWorkflowDetail(wf){ + const c=document.getElementById('enclos-content'); + const p=getWorkflowProgress(wf); + const gc=GEN_COLORS[RACE_GEN[wf.target]]||'#888'; + const pctColor=p.pct>=100?'var(--ok)':p.pct>=50?'var(--warn)':'var(--amour)'; + + function miniCard(name,qty,gen){ + const gcl=GEN_COLORS[gen||RACE_GEN[name]]||'#888'; + return`
+
${getDDImage(name)} + Gen ${gen||RACE_GEN[name]} +
+ ${name} +
`; + } + + let html=` +
+ +
${esc(wf.name)}
+
+
+
+
${getDDImage(wf.target)} + Gen ${RACE_GEN[wf.target]} +
+
+
${esc(wf.name)}
+
+
+
+
${p.done} / ${p.total} — ${p.pct}%
+
+
+
`; + + let stepNum=1; + const totalSteps=wf.steps.length+1; + + // Étape 1 : matières premières + const gc1=GEN_COLORS[1]; + const matDone=wf.materials.reduce((s,m)=>s+Math.min(m.done,m.needed),0); + const matTotal=wf.materials.reduce((s,m)=>s+m.needed,0); + const matPct=matTotal>0?Math.round((matDone/matTotal)*100):0; + const matColor=matPct>=100?'var(--ok)':matPct>=50?'var(--warn)':'var(--amour)'; + html+=`
+
+ Étape ${stepNum}/${totalSteps} + 📦 Matières premières + ${matPct}% +
+
`; + wf.materials.forEach((m,mi)=>{ + const col=raceColor(m.name); + const full=m.done>=m.needed; + html+=`
+ ${miniCard(m.name,m.needed,1)} +
+ + / ${m.needed} +
+ ${full?'
✓ Acquis
':''} +
`; + }); + html+=`
`; + html+=`
`; + stepNum++; + + // Étapes croisements + wf.steps.forEach((st,si)=>{ + const gcS=GEN_COLORS[st.gen]||'#888'; + const isLast=si===wf.steps.length-1; + const stDone=st.crossings.reduce((s,cr)=>s+Math.min(cr.done,cr.needed),0); + const stTotal=st.crossings.reduce((s,cr)=>s+cr.needed,0); + const stPct=stTotal>0?Math.round((stDone/stTotal)*100):0; + const stColor=stPct>=100?'var(--ok)':stPct>=50?'var(--warn)':'var(--amour)'; + html+=`
+
+ Étape ${stepNum}/${totalSteps} + ${isLast?'🎯 ':''}Croisements — Gen ${st.gen} + ${stPct}% +
`; + st.crossings.forEach((cr,ci)=>{ + const crCol=raceColor(cr.race); + const full=cr.done>=cr.needed; + html+=`
+
+ ${miniCard(cr.parentA,cr.couples,RACE_GEN[cr.parentA])} + + + ${miniCard(cr.parentB,cr.couples,RACE_GEN[cr.parentB])} + + ${miniCard(cr.race,cr.needed,st.gen)} +
+
+ Croisements réussis : + + / ${cr.needed} + ${full?'':''} +
+
`; + }); + html+='
'; + if(!isLast)html+=`
`; + stepNum++; + }); + + // Message si terminé + if(p.pct>=100){ + html+=`
+
🎉
+
Workflow terminé !
+
`; + } + + c.innerHTML=html; +} + +function updateWfMaterial(wfId,idx,val){ + const wf=S.workflows.find(w=>w.id===wfId); + if(!wf||!wf.materials[idx])return; + wf.materials[idx].done=Math.max(0,Math.min(wf.materials[idx].needed,parseInt(val)||0)); + save(); + refreshWfProgress(wf); +} + +function updateWfCrossing(wfId,stepIdx,crossIdx,val){ + const wf=S.workflows.find(w=>w.id===wfId); + if(!wf||!wf.steps[stepIdx]||!wf.steps[stepIdx].crossings[crossIdx])return; + const cr=wf.steps[stepIdx].crossings[crossIdx]; + cr.done=Math.max(0,Math.min(cr.needed,parseInt(val)||0)); + save(); + refreshWfProgress(wf); +} + +function refreshWfProgress(wf){ + // Met à jour les barres et pourcentages sans re-render le DOM + const p=getWorkflowProgress(wf); + const pctColor=p.pct>=100?'var(--ok)':p.pct>=50?'var(--warn)':'var(--amour)'; + // Barre globale + const bar=document.getElementById('wf-global-bar'); + if(bar){bar.style.width=p.pct+'%';bar.style.background=pctColor;} + const lbl=document.getElementById('wf-global-lbl'); + if(lbl){lbl.innerHTML=`${p.done} / ${p.total} — ${p.pct}%`;} + // Badges par étape + document.querySelectorAll('[data-wf-step-pct]').forEach(el=>{ + const key=el.dataset.wfStepPct; + let d=0,t=0; + if(key==='mat'){ + wf.materials.forEach(m=>{t+=m.needed;d+=Math.min(m.done,m.needed);}); + }else{ + const si=parseInt(key); + if(wf.steps[si])wf.steps[si].crossings.forEach(cr=>{t+=cr.needed;d+=Math.min(cr.done,cr.needed);}); + } + const sp=t>0?Math.round((d/t)*100):0; + el.style.color=sp>=100?'var(--ok)':sp>=50?'var(--warn)':'var(--amour)'; + el.textContent=sp+'%'; + }); +} + +// ══════════════════════════════════════════ +// INVENTAIRE +// ══════════════════════════════════════════ +let invGenFilter=0; +// Helper : lire le stock avec rétrocompat (ancien format nombre → nouveau {m,f}) +function invGet(inv,name){ + const v=inv[name]; + if(!v)return{m:0,f:0}; + if(typeof v==='number')return{m:Math.ceil(v/2),f:Math.floor(v/2)}; // migration auto + return{m:v.m||0,f:v.f||0}; +} +function invTotal(inv,name){const g=invGet(inv,name);return g.m+g.f;} +function invSet(inv,name,m,f){inv[name]={m:Math.max(0,m),f:Math.max(0,f)};} +function renderInventaire(){ + const c=document.getElementById('enclos-content'); + c.removeAttribute('data-enc'); + const inv=S.inventaire||{}; + + // Onglets filtrage gen + let genTabs=`
+ `; + for(let g=1;g<=10;g++){ + if(g>1&&!RACES_DATA[g])continue; + const gc=GEN_COLORS[g]; + const active=invGenFilter===g; + genTabs+=``; + } + genTabs+='
'; + + // Grille de cards DD + const allGens=[{gen:1,races:['Rousse','Dorée','Amande']}]; + for(let g=2;g<=10;g++){ + if(RACES_DATA[g])allGens.push({gen:g,races:RACES_DATA[g].map(r=>r.name)}); + } + + let cardsHtml=''; + allGens.forEach(({gen,races})=>{ + if(invGenFilter!==0&&invGenFilter!==gen)return; + const gc=GEN_COLORS[gen]; + races.forEach(name=>{ + const stock=invGet(inv,name); + const total=stock.m+stock.f; + const hasStock=total>0; + cardsHtml+=`
+
Gen ${gen}
+ ${getDDImage(name)} +
${name}
+
+
+ + +
+
+ + +
+
+
`; + }); + }); + + // Résumé stock + const totalStock=Object.keys(inv).reduce((s,k)=>s+invTotal(inv,k),0); + const racesOwned=Object.keys(inv).filter(k=>invTotal(inv,k)>0).length; + + c.innerHTML=` +
+
+ Inventaire — Que puis-je produire ? +
+
+ ${totalStock>0?`${totalStock} DD · ${racesOwned} race${racesOwned>1?'s':''}`:''} + + +
+
+
+
Mon stock de dragodindes
+ ${genTabs} +
+ ${cardsHtml} +
+
+
`; +} + +async function resetInventaire(){ + const ok=await window.electronAPI.showConfirm('Réinitialiser l\'inventaire','Remettre à zéro tous les stocks de dragodindes ?','Cette action est irréversible.'); + if(!ok)return; + S.inventaire={}; + save();renderInventaire(); +} + +function calcInventaire(){ + const div=document.getElementById('inv-results'); + if(!div)return; + // Copier le stock : m/f = stock réel, n = bébés produits (sexe inconnu) + const avail={}; + Object.entries(S.inventaire||{}).forEach(([k,v])=>{ + const g=invGet(S.inventaire,k); + if(g.m+g.f>0)avail[k]={m:g.m,f:g.f,n:0}; + }); + + if(Object.keys(avail).length===0){ + div.innerHTML='
Renseigne ton stock de dragodindes ci-dessus puis clique sur Calculer.
'; + return; + } + + // Helpers pour consommer un mâle-capable ou femelle-capable + // Priorité : stock réel (m/f) d'abord, puis neutre (n) + function hasMale(s){return(s.m||0)>0||(s.n||0)>0;} + function hasFemale(s){return(s.f||0)>0||(s.n||0)>0;} + function takeMale(s){if(s.m>0){s.m--;}else{s.n--;}} + function takeFemale(s){if(s.f>0){s.f--;}else{s.n--;}} + function totalOf(s){return(s.m||0)+(s.f||0)+(s.n||0);} + + const generations=[]; + for(let gen=2;gen<=10;gen++){ + const crossingsAtGen=Object.entries(BREEDING_RECIPES) + .filter(([name,parents])=>RACE_GEN[name]===gen) + .map(([name,parents])=>({name,parents})); + + const genResults=[]; + // Allocation équilibrée par round-robin avec contrainte ♂/♀ + 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}, 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='♂';bSex='♀';ok=true; + } + }else if(hasMale(sa)&&hasFemale(sb)){ + takeMale(sa);takeFemale(sb);aSex='♂';bSex='♀';ok=true; + }else if(hasFemale(sa)&&hasMale(sb)){ + takeFemale(sa);takeMale(sb);aSex='♀';bSex='♂';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,gen,details:[]};genResults.push(entry);} + entry.qty++; + entry.details.push({aSex,bSex}); + more=true; + } + } + } + if(genResults.length>0)generations.push({gen,crossings:genResults}); + } + + if(generations.length===0){ + div.innerHTML='
Aucun croisement possible avec le stock actuel. Il faut au moins 2 races différentes.
'; + return; + } + + function miniCard(name,qty,gen,genderInfo){ + const gcl=GEN_COLORS[gen||RACE_GEN[name]]||'#888'; + let genderHtml=''; + if(genderInfo){ + const parts=[]; + if(genderInfo.males>0)parts.push(`♂${genderInfo.males}`); + if(genderInfo.females>0)parts.push(`♀${genderInfo.females}`); + if(genderInfo.neutral>0)parts.push(`?${genderInfo.neutral}`); + genderHtml=`${parts.join(' ')}`; + } + return`
+
${getDDImage(name)} + Gen ${gen||RACE_GEN[name]} +
+ ${name} + ×${qty} + ${genderHtml} +
`; + } + + const totalProduced=generations.reduce((s,g)=>s+g.crossings.reduce((ss,c)=>ss+c.qty,0),0); + + let html=`
+
+ ${totalProduced} + bébé${totalProduced>1?'s':''} possible${totalProduced>1?'s':''} sur ${generations.length} génération${generations.length>1?'s':''} +
+
`; + + generations.forEach(({gen,crossings},gIdx)=>{ + const gc=GEN_COLORS[gen]||'#888'; + const genTotal=crossings.reduce((s,c)=>s+c.qty,0); + const isLast=gIdx===generations.length-1; + html+=`
+
+ Gen ${gen} + ${genTotal} bébé${genTotal>1?'s':''} +
`; + crossings.forEach(cr=>{ + // Compter ♂/♀/? utilisés par parent + const aGender={males:0,females:0,neutral:0},bGender={males:0,females:0,neutral:0}; + (cr.details||[]).forEach(d=>{ + if(d.aSex==='♂')aGender.males++;else if(d.aSex==='♀')aGender.females++;else aGender.neutral++; + if(d.bSex==='♂')bGender.males++;else if(d.bSex==='♀')bGender.females++;else bGender.neutral++; + }); + html+=`
+ ${miniCard(cr.parents[0],cr.qty,RACE_GEN[cr.parents[0]],aGender)} + + + ${miniCard(cr.parents[1],cr.qty,RACE_GEN[cr.parents[1]],bGender)} + + ${miniCard(cr.name,cr.qty,gen)} +
`; + }); + html+='
'; + if(!isLast)html+=`
`; + }); + + // DD non utilisées (stock réel uniquement, pas les bébés produits) + const unused=Object.entries(avail).filter(([,v])=>(v.m+v.f)>0).sort((a,b)=>(b[1].m+b[1].f)-(a[1].m+a[1].f)); + if(unused.length>0){ + html+=`
+
DD restantes (non utilisées)
+
`; + unused.forEach(([name,stock])=>{ + const parts=[]; + if(stock.m>0)parts.push(`♂${stock.m}`); + if(stock.f>0)parts.push(`♀${stock.f}`); + html+=`
+
${getDDImage(name)} + Gen ${RACE_GEN[name]} +
+ ${name} + ${parts.join(' ')} +
`; + }); + html+=`
`; + } + + div.innerHTML=html; +} + // ══════════════════════════════════════════ // NOTIFICATIONS MOBILES (ntfy.sh) // ══════════════════════════════════════════ @@ -2248,6 +3269,10 @@ function sendNtfyNotif(title,message){ } let lastT=0; +// Cache DOM pour éviter des centaines de getElementById par tick +const elCache=new Map(); +function gel(id){let el=elCache.get(id);if(el&&el.isConnected)return el;el=document.getElementById(id);if(el)elCache.set(id,el);return el;} +function clearElCache(){elCache.clear();} function loop(ts){requestAnimationFrame(loop);if(ts-lastT<500)return;lastT=ts;updateLive();} // ══════════════════════════════════════════ diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 0000000..0eec5a0 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,114 @@ +# Plan — Polish UI & Features v1.2.0 + +## Vue d'ensemble +7 améliorations validées par l'utilisateur pour le Minuteur Dragodinde. + +--- + +## Phase 1 — Fondations visuelles (rapide, 0 risque) + +### 1.1 Tooltips sur les pills de stats +- **Quoi** : Ajouter un `title="Sérénité"`, `title="Endurance"` etc. sur chaque pill +- **Impact** : Ligne ~1654 (PILL_DEFS forEach) — ajouter un champ `label` et l'injecter en `title=` +- **Complexité** : Très faible (~5 min) +- **Risque** : Aucun + +### 1.2 Indication visuelle "en retard" (pause longue) +- **Quoi** : Si un timer est en pause depuis > 5 min, colorer le tab en orange/rouge +- **Impact** : + - Lignes 1386, 1985 (updateTabDots) — ajouter classe `.paused-long` + - Détection : `enc.timer.pausedAt && (Date.now() - enc.timer.pausedAt) > 300000` + - CSS : nouveau style `.tab.paused-long` +- **Complexité** : Faible (~15 min) +- **Risque** : Aucun + +--- + +## Phase 2 — Barre de progression & animations (~1h) + +### 2.1 Barre de progression sous chaque tab d'enclos +- **Quoi** : Fine barre colorée (3-4px) sous chaque onglet montrant le % d'avancement global +- **Impact** : + - Lignes 1341-1420 (renderTabs) — ajouter un `
` dans chaque tab + - Lignes 1981-1986 (updateTabDots) — mettre à jour la largeur via `gel()` + - `enclosGlobalState()` fournit déjà `globalMax`, `allDone`, `ddDone` + - Calcul % : `ddDone / enc.dragodindes.length` ou basé sur le temps écoulé vs totalSec + - CSS : `.tab-progress { height:3px; background:var(--ok); transition:width .5s }` +- **Complexité** : Moyenne (~30 min) +- **Risque** : Faible — touche le rendu des tabs mais pas la logique + +### 2.2 Animations de transition entre onglets +- **Quoi** : Fade-in quand on switch de tab (opacity 0→1) +- **Impact** : + - Ligne 40 : CSS `.enclos-content` — ajouter `transition: opacity .2s` + - Ligne 1465 (renderContent) — set opacity 0 avant innerHTML, puis 1 après un rAF + - Alternative plus simple : classe `.fade-in` avec animation CSS +- **Complexité** : Faible (~20 min) +- **Risque** : Faible — purement visuel, pas de logique métier + +--- + +## Phase 3 — Thème clair/sombre (~1h30) + +### 3.1 Système de thème +- **Quoi** : Toggle dans le header pour basculer entre dark (actuel) et light +- **Impact** : + - Lignes 9-14 : Dupliquer les variables dans `[data-theme="light"]` avec des couleurs claires + - ~320 occurrences de couleurs dans le fichier, MAIS la majorité utilise déjà `var(--xxx)` + - **Problème principal** : les couleurs inline dans le JS (ex: `style="color:rgb(192,96,255)"`) ne changent PAS avec les variables CSS + - Il faudra auditer les styles inline dans renderDDs, renderDashboard, etc. + - Sauvegarde du choix dans `S.theme` (persisté via save()) + - Toggle : bouton 🌙/☀ dans le header +- **Complexité** : Moyenne-élevée (~1h30) +- **Risque** : Moyen — beaucoup de couleurs inline à vérifier, risque d'oublis visuels +- **Effet de bord** : Les couleurs des jauges (--ser, --end, --mat, --amour, --xp) doivent rester lisibles sur fond clair + +--- + +## Phase 4 — Raccourcis clavier (~30 min) + +### 4.1 Raccourcis globaux +- **Quoi** : + - `Espace` : Play/Pause le timer de l'enclos actif + - `1-6` : Switch vers l'enclos 1 à 6 + - `D` : Dashboard + - `N` : Ajouter une DD dans l'enclos actif +- **Impact** : + - Ajouter un `document.addEventListener('keydown', ...)` en fin de fichier + - Vérifier que `document.activeElement` n'est pas un input (sinon les raccourcis interfèrent avec la saisie) +- **Complexité** : Faible (~30 min) +- **Risque** : Moyen — doit ignorer les frappes quand un input est focus + +--- + +## Phase 5 — Planificateur journalier (~2h) + +### 5.1 Objectifs du jour +- **Quoi** : Nouvel onglet "Objectifs" où l'utilisateur définit ce qu'il veut accomplir aujourd'hui + - Ex: "Monter 5 DD au level 34", "Baisser sérénité de 3 DD à -5000" + - Progression auto-calculée à partir des timers actifs + - Récap fin de journée +- **Impact** : + - Nouvel onglet dans SPECIAL_TABS + - Nouveau champ `S.dailyGoals` dans l'état + - Calcul de progression basé sur les stats actuelles vs objectifs + - Interface : liste d'objectifs avec barres de progression +- **Complexité** : Élevée (~2h) +- **Risque** : Moyen — nouvelle feature isolée, mais il faut définir précisément ce qu'est "faisable en une journée humaine" (calcul basé sur les taux de jauges) +- **Note utilisateur** : "il faut calculer ce qui est faisable et productible en une journée humaine" + +--- + +## Ordre d'exécution recommandé + +| Ordre | Phase | Temps estimé | Dépendances | +|-------|-------|-------------|-------------| +| 1 | 1.1 Tooltips pills | ~5 min | Aucune | +| 2 | 1.2 Indication retard | ~15 min | Aucune | +| 3 | 2.1 Barre progression tabs | ~30 min | Aucune | +| 4 | 2.2 Animations transition | ~20 min | Aucune | +| 5 | 4.1 Raccourcis clavier | ~30 min | Aucune | +| 6 | 3.1 Thème clair/sombre | ~1h30 | Phases 1-2 (pour vérifier le rendu) | +| 7 | 5.1 Planificateur journalier | ~2h | Aucune | + +**Total estimé : ~5h**