Files
farm-tracker-sao/renderer/index.html
s a5d1725ee7 feat: stats historique enrichies en kills/min avec exp, kamas et top item
- Kills/h remplacé par kills/min partout (graphique + tableau)
- Hero : 3 nouvelles cartes (kills/min moyen + meilleur, exp totale, kamas totaux)
- Tableau : ajout colonnes exp/min, kamas, cols/min, top item droppé

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:14:02 +02:00

2075 lines
90 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Farm Tracker</title>
<style>
/* Règle imposée : aucun coin arrondi nulle part dans l'application. */
* {
border-radius: 0px !important;
}
:root{
--bg:#0B0F0C;
--bg-panel:#121810;
--bg-panel-2:#171F15;
--border:#26301F;
--text:#EDEAE0;
--text-dim:#8C9A8A;
--corrupt:#9B5DE5;
--corrupt-glow:rgba(155,93,229,.28);
--moss:#6B8F5C;
--boss:#E0556B;
--gold:#F2C94C;
--copper:#C97B4A;
--paused:#C9A227;
--font-display:'IBM Plex Mono', ui-monospace, monospace;
--font-body:-apple-system, system-ui, 'Segoe UI', sans-serif;
--font-mono:'IBM Plex Mono', ui-monospace, 'SF Mono', monospace;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
[hidden]{display:none !important;}
body{
background:
radial-gradient(900px 500px at 12% -10%, var(--corrupt-glow), transparent 60%),
var(--bg);
color:var(--text);
font-family:var(--font-body);
min-height:100vh;
-webkit-font-smoothing:antialiased;
user-select:none;
}
input, .feed, .bars, .table-scroll, .drop-list{ user-select:text; }
:focus-visible{outline:2px solid var(--gold); outline-offset:2px;}
.app{max-width:1100px;margin:0 auto;padding:20px 18px 60px;}
/* ---------- Barre du shell (toujours visible une fois passé l'écran de dossier) ---------- */
.shell-topbar{display:flex;align-items:center;gap:10px;padding:6px 2px 22px;flex-wrap:wrap;
-webkit-app-region:drag;}
.shell-topbar button{-webkit-app-region:no-drag;}
.brand{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:.06em;cursor:pointer;}
.brand-mark{font-size:18px;}
.brand-text{font-size:13px;color:var(--text-dim);letter-spacing:.18em;font-family:var(--font-mono);}
.spacer{flex:1;}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text-dim);
font-family:var(--font-body);font-size:12px;padding:7px 12px;cursor:pointer;
white-space:nowrap;text-decoration:none;display:inline-flex;align-items:center;}
.btn-ghost:hover{color:var(--text);border-color:#3a4632;}
.btn-ghost.small{font-size:11.5px;padding:5px 10px;}
.btn-ghost.danger:hover{color:var(--boss);border-color:var(--boss);}
.btn-ghost.accent{color:var(--paused);border-color:#4a4322;}
/* ---------- Écran de choix du dossier (une seule fois, global) ---------- */
.connect-screen{display:flex;justify-content:center;padding:8vh 0;}
.connect-card{max-width:480px;background:var(--bg-panel);border:1px solid var(--border);
padding:36px 32px;text-align:left;}
.eyebrow{font-family:var(--font-mono);font-size:11px;letter-spacing:.16em;color:var(--corrupt);
margin:0 0 10px;text-transform:uppercase;}
.connect-card h1{font-size:22px;line-height:1.3;margin:0 0 14px;}
.lead{color:var(--text-dim);font-size:14px;line-height:1.6;margin:0 0 22px;}
.lead code{background:var(--bg-panel-2);border:1px solid var(--border);
padding:1px 6px;font-family:var(--font-mono);font-size:12.5px;color:var(--text);}
.btn-primary{background:var(--corrupt);color:#fff;border:none;
font-family:var(--font-body);font-weight:600;font-size:14px;padding:12px 20px;cursor:pointer;
width:100%;transition:transform .12s ease, background .12s ease;}
.btn-primary:hover{background:#a86cf0;}
.btn-primary:active{transform:scale(.98);}
.hint{font-size:12px;color:var(--text-dim);margin-top:16px;line-height:1.6;}
.error-box{margin-top:16px;font-size:13px;color:var(--boss);background:rgba(224,85,107,.08);
border:1px solid rgba(224,85,107,.3);padding:10px 12px;line-height:1.5;}
/* ---------- Écran d'accueil (hub) ---------- */
.hub-screen{padding:4vh 0;}
.hub-screen h1{font-size:24px;margin:0 0 6px;}
.hub-lead{color:var(--text-dim);font-size:14px;margin:0 0 28px;}
.app-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;}
.app-tile{background:var(--bg-panel);border:1px solid var(--border);padding:22px 20px;
text-align:left;cursor:pointer;color:var(--text);font-family:var(--font-body);
display:flex;flex-direction:column;gap:10px;transition:border-color .15s ease, transform .1s ease;}
.app-tile:hover{border-color:var(--corrupt);transform:translateY(-2px);}
.app-tile:active{transform:translateY(0);}
.app-tile-icon{font-size:26px;}
.app-tile-title{font-size:15px;font-weight:700;}
.app-tile-desc{font-size:12.5px;color:var(--text-dim);line-height:1.5;}
.app-tile-status{font-family:var(--font-mono);font-size:10.5px;color:var(--moss);
text-transform:uppercase;letter-spacing:.08em;}
.app-tile-placeholder{border-style:dashed;color:var(--text-dim);align-items:center;
justify-content:center;text-align:center;opacity:.5;cursor:default;}
.app-tile-placeholder:hover{border-color:var(--border);transform:none;}
/* ---------- Vue d'un outil (Farm Tracker) ---------- */
.app-screen{display:flex;flex-direction:column;gap:18px;}
.app-subbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim);
border:1px solid var(--border);padding:6px 12px;background:var(--bg-panel);}
.dot{width:8px;height:8px;background:#555;flex:none;}
.dot.live{background:#5BD17C;animation:pulse 1.8s infinite;}
.dot.error{background:var(--boss);}
.dot.paused{background:var(--paused);}
@keyframes pulse{
0%{box-shadow:0 0 0 0 rgba(91,209,124,.55);}
70%{box-shadow:0 0 0 7px rgba(91,209,124,0);}
100%{box-shadow:0 0 0 0 rgba(91,209,124,0);}
}
.global-msg{font-family:var(--font-mono);font-size:12px;color:var(--gold);
background:rgba(242,201,76,.08);border:1px solid rgba(242,201,76,.25);
padding:8px 12px;display:none;}
.global-msg.error{color:var(--boss);background:rgba(224,85,107,.08);border-color:rgba(224,85,107,.3);}
.global-msg.show{display:block;}
.hero{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
.hero.hero-economy{grid-template-columns:repeat(2,1fr);}
.hero-stat{background:var(--bg-panel);border:1px solid var(--border);padding:18px 18px 16px;}
.hero-stat.estimate{border-style:dashed;border-color:#3a4225;}
.hero-number{font-family:var(--font-display);font-size:24px;margin:6px 0 4px;letter-spacing:.02em;}
.hero-sub{font-family:var(--font-mono);font-size:11.5px;color:var(--text-dim);margin:0;}
.muted{color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0;font-family:var(--font-body);font-size:11px;}
.economy-note,.session-note{font-size:12px;color:var(--text-dim);line-height:1.5;margin:-4px 0 0;}
.section-toolbar{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:2px;}
.section-toolbar h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0;font-weight:600;}
.grid{display:grid;grid-template-columns:1fr 1.3fr;gap:14px;}
.panel{background:var(--bg-panel);border:1px solid var(--border);padding:18px 18px 14px;}
.panel-head-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
.panel-head-row h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0;font-weight:600;}
.panel-head-row .spacer{flex:1;}
.panel h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0 0 14px;font-weight:600;}
.filter-input{background:var(--bg-panel-2);border:1px solid var(--border);color:var(--text);
font-family:var(--font-body);font-size:12px;padding:6px 10px;min-width:140px;}
.filter-input::placeholder{color:var(--text-dim);}
select.filter-input{cursor:pointer;max-width:260px;}
.bars{display:flex;flex-direction:column;gap:14px;max-height:340px;overflow-y:auto;padding-right:4px;}
.bar-row .bar-label{display:flex;justify-content:space-between;align-items:baseline;
font-size:13.5px;margin-bottom:6px;}
.bar-row .bar-count{font-family:var(--font-mono);font-size:13px;color:var(--text-dim);}
.bar-track{height:7px;background:var(--bg-panel-2);overflow:hidden;}
.bar-fill{height:100%;transition:width .4s ease;}
.table-scroll{max-height:340px;overflow-y:auto;border-bottom:1px solid var(--border);margin-bottom:10px;}
.loot-table{width:100%;border-collapse:collapse;}
.loot-table thead th{position:sticky;top:0;background:var(--bg-panel);z-index:1;
font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;
color:var(--text-dim);text-align:left;padding-bottom:8px;border-bottom:1px solid var(--border);
font-weight:600;}
.loot-table th.qty-cell,.loot-table th.rate-cell,.loot-table th.price-cell,.loot-table th.value-cell{text-align:right;}
.loot-table td{padding:8px 6px 8px 0;font-size:13.5px;border-bottom:1px solid var(--border);vertical-align:middle;}
.loot-table tr:last-child td{border-bottom:none;}
.name-cell{display:flex;align-items:center;gap:8px;}
.swatch{width:8px;height:8px;flex:none;}
.qty-cell{font-family:var(--font-mono);text-align:right;white-space:nowrap;}
.rate-cell{font-family:var(--font-mono);text-align:right;color:var(--text-dim);font-size:11.5px;
white-space:nowrap;padding-left:10px;}
.price-cell{text-align:right;padding-left:10px;}
.value-cell{font-family:var(--font-mono);text-align:right;padding-left:10px;white-space:nowrap;color:var(--copper);}
.price-input{width:64px;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-mono);font-size:12.5px;
padding:5px 7px;text-align:right;}
.price-input::-webkit-outer-spin-button,.price-input::-webkit-inner-spin-button{margin:0;}
.loot-total-row{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--font-mono);font-size:13px;padding-top:2px;}
.loot-total-row .value-cell{color:var(--copper);font-weight:600;padding-left:0;}
.feed-panel{min-height:120px;}
.feed{display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto;}
.feed-empty{color:var(--text-dim);font-size:13px;padding:6px 0;}
.feed-item{display:flex;align-items:center;gap:10px;font-size:13px;padding:8px 10px;
background:var(--bg-panel-2);border-left:3px solid var(--moss);
animation:feedIn .35s ease;}
.feed-item .feed-time{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);
flex:none;width:54px;}
.feed-item .feed-mob{color:var(--text-dim);font-size:11.5px;white-space:nowrap;}
.feed-item .feed-items{flex:1;display:flex;flex-wrap:wrap;gap:4px 10px;}
@keyframes feedIn{
from{opacity:0; transform:translateY(-6px);}
to{opacity:1; transform:translateY(0);}
}
.droprow{margin-bottom:12px;}
.droprow:last-child{margin-bottom:0;}
.droprow-label{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;margin-bottom:5px;}
.droprow-pct{font-family:var(--font-mono);font-size:12.5px;}
.droprow-sub{font-family:var(--font-mono);font-size:10.5px;color:var(--text-dim);margin-top:3px;}
.drop-list{max-height:340px;overflow-y:auto;padding-right:4px;}
.drop-share-note{font-size:11.5px;color:var(--text-dim);line-height:1.5;margin:-2px 0 12px;}
/* ---------- Drop Board ---------- */
.drop-board{display:flex;flex-direction:column;gap:32px;}
.drop-board-mob-header{display:flex;align-items:baseline;gap:14px;
border-bottom:1px solid var(--border);padding-bottom:10px;margin-bottom:14px;}
.drop-board-mob-name{font-family:var(--font-mono);font-size:11px;font-weight:700;
letter-spacing:.18em;text-transform:uppercase;color:var(--text);}
.drop-board-mob-kills{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);}
.drop-board-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;}
.drop-card{background:var(--bg-panel-2);border:1px solid var(--border);border-left:3px solid;
padding:14px 14px 12px;display:flex;flex-direction:column;gap:5px;cursor:context-menu;}
.drop-card:hover{border-color:var(--border) var(--border) var(--border);}
.drop-card-pct{font-family:var(--font-display);font-size:28px;line-height:1;letter-spacing:.01em;}
.drop-card-name{font-size:12.5px;line-height:1.4;}
.drop-card-sub{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);
margin-top:auto;padding-top:6px;}
/* ---------- Badge communauté + sync ---------- */
.community-badge{font-family:var(--font-mono);font-size:10px;color:var(--moss);
background:rgba(107,143,92,.12);border:1px solid rgba(107,143,92,.28);
padding:2px 8px;text-transform:uppercase;letter-spacing:.08em;}
.sync-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px;}
.sync-url-input{flex:1;min-width:180px;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-body);font-size:12px;padding:6px 10px;}
.sync-url-input::placeholder{color:var(--text-dim);}
/* ---------- Modals sync ---------- */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:900;display:none;
align-items:center;justify-content:center;}
.modal-overlay.visible{display:flex;}
.modal-overlay-2{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:910;display:none;
align-items:center;justify-content:center;}
.modal-overlay-2.visible{display:flex;}
.modal-box{background:var(--bg-panel);border:1px solid var(--border);padding:28px 28px 22px;
max-width:480px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.7);}
.modal-box h3{font-size:15px;margin:0 0 12px;font-weight:700;}
.modal-desc{font-size:13px;color:var(--text-dim);line-height:1.65;margin:0 0 16px;}
.modal-desc strong{color:var(--text);}
.modal-url-label{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;
text-transform:uppercase;color:var(--text-dim);margin-bottom:5px;}
.modal-url-input{width:100%;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-mono);font-size:13px;padding:8px 10px;margin-bottom:16px;}
.modal-url-input::placeholder{color:var(--text-dim);}
.modal-files{background:var(--bg-panel-2);border:1px solid var(--border);
padding:10px 12px;margin-bottom:18px;display:flex;flex-direction:column;gap:6px;}
.modal-file-row{display:flex;align-items:baseline;gap:8px;font-size:11.5px;}
.modal-file-arrow{font-size:11px;flex:none;}
.modal-file-path{font-family:var(--font-mono);font-size:10.5px;color:var(--text-dim);
word-break:break-all;}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;}
.modal-warn{background:rgba(242,201,76,.07);border:1px solid rgba(242,201,76,.2);
padding:12px 14px;margin-bottom:18px;font-size:13px;color:var(--gold);line-height:1.6;}
.modal-warn strong{display:block;margin-bottom:4px;font-size:13.5px;}
/* ---------- Menu contextuel "qui drop cet item ?" ---------- */
.item-ctx-menu{position:fixed;z-index:999;background:var(--bg-panel);
border:1px solid var(--border);padding:12px 14px;
min-width:220px;max-width:320px;box-shadow:0 4px 24px rgba(0,0,0,.65);
display:none;pointer-events:none;}
.item-ctx-menu.visible{display:block;pointer-events:auto;}
.item-ctx-title{font-family:var(--font-mono);font-size:10px;letter-spacing:.12em;
color:var(--text-dim);text-transform:uppercase;margin-bottom:10px;}
.item-ctx-row{padding:5px 0;border-bottom:1px solid var(--border);}
.item-ctx-row:last-child{border-bottom:none;}
.item-ctx-mob{display:flex;justify-content:space-between;align-items:baseline;gap:8px;}
.item-ctx-mob-name{font-size:13px;}
.item-ctx-mob-pct{font-family:var(--font-mono);font-size:12px;}
.item-ctx-mob-sub{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:2px;}
.item-ctx-empty{font-size:12px;color:var(--text-dim);}
@media (max-width:760px){
.grid{grid-template-columns:1fr;}
.hero{grid-template-columns:1fr;}
.hero.hero-economy{grid-template-columns:1fr;}
}
/* ---------- Historique des sessions ---------- */
.chart-wrap{position:relative;margin:0 0 2px;}
.chart-empty{font-size:13px;color:var(--text-dim);padding:18px 0 6px;}
.chart-label-row{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px;}
.charts-2col{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.sessions-table{width:100%;border-collapse:collapse;}
.sessions-table thead th{position:sticky;top:0;background:var(--bg-panel);z-index:1;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);text-align:left;padding:0 8px 8px 0;border-bottom:1px solid var(--border);font-weight:600;white-space:nowrap;}
.sessions-table th.r,.sessions-table td.r{text-align:right;padding-left:12px;}
.sessions-table td{padding:9px 8px 9px 0;font-size:13px;border-bottom:1px solid var(--border);vertical-align:middle;}
.sessions-table tr:last-child td{border-bottom:none;}
.sessions-table td.mono{font-family:var(--font-mono);color:var(--text-dim);}
.sessions-table td.hl{font-family:var(--font-mono);color:var(--text);}
.sessions-table td.dim{font-size:12px;color:var(--text-dim);}
.hist-empty{padding:40px 0;text-align:center;color:var(--text-dim);font-size:14px;line-height:1.8;}
@media(max-width:760px){.charts-2col{grid-template-columns:1fr;}}
/* ---------- Graphes en direct ---------- */
.charts-3col{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
@media(max-width:760px){.charts-3col{grid-template-columns:1fr;}}
.live-chart-header{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--font-mono);font-size:11px;margin-bottom:5px;}
.live-chart-header .chart-label{color:var(--text-dim);}
.live-chart-header .chart-val{font-size:12px;font-weight:600;}
</style>
</head>
<body>
<div class="app">
<div class="shell-topbar">
<div class="brand" id="brandHome"><span class="brand-mark"></span><span class="brand-text">FARM TRACKER</span></div>
<div class="spacer"></div>
<button class="btn-ghost" id="homeBtn" hidden>🏠 Accueil</button>
<button class="btn-ghost" id="changeFolderBtn" hidden>Changer de dossier</button>
<button class="btn-ghost small" id="checkUpdateBtn" title="Vérifier les mises à jour">Mises à jour</button>
</div>
<!-- ============ ÉCRAN 1 : choix du dossier (une seule fois, global) ============ -->
<section class="connect-screen" id="folderScreen">
<div class="connect-card">
<p class="eyebrow">Étape 1</p>
<h1>Connecte ton dossier Minecraft</h1>
<p class="lead">
Choisis ton dossier <code>.minecraft</code> (généralement <code>~/.minecraft</code>
sur Linux), ou directement le dossier <code>logs</code>. Cette étape ne se fait
qu'une fois : tous les outils de l'appli s'appuieront sur ce même dossier.
</p>
<button class="btn-primary" id="pickFolderBtn">Choisir le dossier</button>
<p class="hint">
Tout est sauvegardé automatiquement sur ton disque — rien à exporter ni à
recharger toi-même. Au prochain lancement, l'appli retrouve directement ton
dossier et reprend exactement où tu en étais.
</p>
<div class="error-box" id="folderErrorBox" hidden></div>
</div>
</section>
<!-- ============ ÉCRAN 2 : accueil / choix d'outil ============ -->
<section class="hub-screen" id="hubScreen" hidden>
<p class="eyebrow">Accueil</p>
<h1>Tes outils</h1>
<p class="hub-lead">Choisis un outil. Les autres continuent de tourner en arrière-plan, même quand tu n'es pas sur leur écran.</p>
<div class="app-grid">
<button class="app-tile" id="tileFarmTracker">
<span class="app-tile-icon"></span>
<span class="app-tile-title">Farm Tracker</span>
<span class="app-tile-desc">Suivi de loot Minecraft en direct : mobs, items, cols, chances de drop.</span>
<span class="app-tile-status" id="tileFarmTrackerStatus">&nbsp;en attente</span>
</button>
<button class="app-tile" id="tileDropBoard">
<span class="app-tile-icon">🎲</span>
<span class="app-tile-title">Drop Board</span>
<span class="app-tile-desc">Vue visuelle des chances de drop par mob, sous forme de cartes.</span>
<span class="app-tile-status" id="tileDropBoardStatus">&nbsp;en attente</span>
</button>
<button class="app-tile" id="tileHistory">
<span class="app-tile-icon">📊</span>
<span class="app-tile-title">Historique</span>
<span class="app-tile-desc">Statistiques des sessions passées et évolution du farm au fil du temps.</span>
<span class="app-tile-status" id="tileHistoryStatus">&nbsp;0 session</span>
</button>
</div>
</section>
<!-- ============ ÉCRAN 3 : outil Farm Tracker ============ -->
<section class="app-screen" id="appFarmTracker" hidden>
<div class="app-subbar">
<div class="status" id="statusPill"><span class="dot" id="statusDot"></span><span id="statusText"></span></div>
<button class="btn-ghost accent" id="pauseBtn">⏸ Pause</button>
<button class="btn-ghost" id="resetBtn">Réinitialiser la session</button>
</div>
<p class="global-msg" id="globalMsg"></p>
<div class="hero">
<div class="hero-stat">
<p class="eyebrow">Session</p>
<p class="hero-number" id="timer">00:00:00</p>
<p class="hero-sub" id="lastEvent">en attente de loot…</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Mobs lootés</p>
<p class="hero-number" id="totalKills">0</p>
<p class="hero-sub" id="killsRate">0,0 / min</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Exp cumulée</p>
<p class="hero-number" id="expTotal">0</p>
<p class="hero-sub" id="expRate">0,0 / min</p>
</div>
</div>
<div class="hero hero-economy">
<div class="hero-stat">
<p class="eyebrow">Cols gagnés en tuant</p>
<p class="hero-number" id="colsKill">0</p>
<p class="hero-sub" id="colsKillRate">0,0 / min</p>
</div>
<div class="hero-stat estimate">
<p class="eyebrow">Cols gagnés en vendant <span class="muted">(estimé)</span></p>
<p class="hero-number" id="colsSell">≈ 0</p>
<p class="hero-sub" id="colsSellRate">0,0 / min</p>
</div>
</div>
<p class="economy-note">Le jeu ne transmet pas les ventes : fixe un prix par item dans le tableau « Loot » ci-dessous. Tes prix sont sauvegardés automatiquement.</p>
<div class="panel" id="liveChartsPanel" hidden>
<div class="panel-head-row">
<h2>Progression dans le temps <span class="muted">— snapshot toutes les 30s</span></h2>
</div>
<div class="charts-3col">
<div>
<div class="live-chart-header">
<span class="chart-label">Kills / min</span>
<span class="chart-val" id="liveChartKillsVal" style="color:var(--moss)"></span>
</div>
<div class="chart-wrap" id="liveChartKills"></div>
<div class="chart-label-row" id="liveChartKillsTime"><span></span><span></span></div>
</div>
<div>
<div class="live-chart-header">
<span class="chart-label">XP / min</span>
<span class="chart-val" id="liveChartExpVal" style="color:var(--gold)"></span>
</div>
<div class="chart-wrap" id="liveChartExp"></div>
<div class="chart-label-row" id="liveChartExpTime"><span></span><span></span></div>
</div>
<div>
<div class="live-chart-header">
<span class="chart-label">Cols / min</span>
<span class="chart-val" id="liveChartColsVal" style="color:var(--copper)"></span>
</div>
<div class="chart-wrap" id="liveChartCols"></div>
<div class="chart-label-row" id="liveChartColsTime"><span></span><span></span></div>
</div>
</div>
</div>
<div class="section-toolbar">
<h2>Stats de farm — cette session</h2>
</div>
<p class="session-note">Sauvegardées en continu sur le disque. "Réinitialiser la session" remet juste les compteurs à zéro — les chances de drop, elles, ne sont jamais perdues.</p>
<div class="grid">
<div class="panel">
<div class="panel-head-row">
<h2>Mobs</h2>
<div class="spacer"></div>
<input type="text" id="mobFilterInput" class="filter-input" placeholder="Filtrer…">
</div>
<div id="mobsList" class="bars"></div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Loot</h2>
<div class="spacer"></div>
<input type="text" id="itemFilterInput" class="filter-input" placeholder="Filtrer les items…">
</div>
<p style="font-size:11px;color:var(--text-dim);margin:-10px 0 8px;">Clic droit sur un item pour voir qui le drop.</p>
<div class="table-scroll">
<table class="loot-table">
<thead>
<tr>
<th>Item</th>
<th class="qty-cell">Qté</th>
<th class="rate-cell">/min</th>
<th class="price-cell">Prix</th>
<th class="value-cell">Valeur</th>
</tr>
</thead>
<tbody id="itemsList"></tbody>
</table>
</div>
<div class="loot-total-row">
<span>Total estimé</span>
<span class="value-cell" id="sellTotalCell">0</span>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Chance de drop <span class="muted">— cumulée, fichier séparé et partageable</span></h2>
<span class="community-badge" id="communityBadge" hidden>🌐 Communauté</span>
<div class="spacer"></div>
<button class="btn-ghost small" id="toggleCommunityBtn" hidden>👤 Mes stats perso</button>
<select id="dropMobSelect" class="filter-input"></select>
</div>
<p class="drop-share-note">
Stockées dans un fichier à part (lisible, noms d'items décodés). Exporte / importe manuellement,
ou utilise le <strong>sync communautaire</strong> pour envoyer tes stats au serveur et recevoir
celles de tout le monde en une fois. Clic droit sur un item pour voir quel mob le drop.
</p>
<div class="panel-head-row" style="margin-top:-6px;">
<button class="btn-ghost small" id="exportDropBtn">📤 Exporter mes stats</button>
<button class="btn-ghost small" id="importDropBtn">📥 Importer / fusionner…</button>
<div class="spacer"></div>
<button class="btn-ghost small danger" id="resetDropBtn">Réinitialiser</button>
</div>
<div class="sync-row">
<input type="text" id="syncServerUrl" class="sync-url-input filter-input" placeholder="URL serveur sync (ex: http://monvps.com:3742)">
<button class="btn-ghost small" id="syncDropBtn">🔄 Sync communauté</button>
</div>
<div id="dropStatsBody" class="drop-list" style="margin-top:14px;"></div>
</div>
<div class="panel feed-panel">
<h2>Flux en direct</h2>
<div id="feed" class="feed"><p class="feed-empty">Les nouveaux drops apparaîtront ici…</p></div>
</div>
</section>
<!-- ============ ÉCRAN 5 : Historique des sessions ============ -->
<section class="app-screen" id="appHistory" hidden>
<div class="hero" id="histHero" hidden>
<div class="hero-stat">
<p class="eyebrow">Sessions</p>
<p class="hero-number" id="histTotalSessions">0</p>
<p class="hero-sub">enregistrées</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kills totaux</p>
<p class="hero-number" id="histTotalKills">0</p>
<p class="hero-sub" id="histAvgKills">moy. 0 / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Temps total</p>
<p class="hero-number" id="histTotalTime">0h 00m</p>
<p class="hero-sub" id="histAvgTime">moy. 0h / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kills/min moyen</p>
<p class="hero-number" id="histAvgRate">0</p>
<p class="hero-sub" id="histBestRate">meilleur 0/min</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Exp totale</p>
<p class="hero-number" id="histTotalExp">0</p>
<p class="hero-sub" id="histAvgExp">moy. 0 / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kamas totaux</p>
<p class="hero-number" id="histTotalKamas">0</p>
<p class="hero-sub" id="histAvgKamas">moy. 0 / session</p>
</div>
</div>
<div id="histEmptyState">
<div class="hist-empty">
<div style="font-size:36px;margin-bottom:12px;">📊</div>
<strong>Pas encore d'historique</strong><br>
Les sessions sont sauvegardées automatiquement quand tu cliques sur<br>
<strong>"Réinitialiser la session"</strong> dans Farm Tracker.
</div>
</div>
<div id="histChartsArea" hidden>
<div class="panel" style="margin-bottom:14px;">
<div class="panel-head-row">
<h2>Kills par session</h2>
<div class="spacer"></div>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);" id="histKillsAvgLabel"></span>
</div>
<div class="chart-wrap" id="chartKills"></div>
<div class="chart-label-row" id="chartKillsLabels"></div>
</div>
<div class="charts-2col" style="margin-bottom:14px;">
<div class="panel">
<h2>Kills / min</h2>
<div class="chart-wrap" id="chartRate"></div>
<div class="chart-label-row" id="chartRateLabels"></div>
</div>
<div class="panel">
<h2>Exp par session</h2>
<div class="chart-wrap" id="chartExp"></div>
<div class="chart-label-row" id="chartExpLabels"></div>
</div>
</div>
</div>
<div class="panel" id="histSessionsPanel" hidden>
<div class="panel-head-row">
<h2>Détail des sessions</h2>
<div class="spacer"></div>
<button class="btn-ghost small danger" id="clearHistoryBtn">Effacer l'historique</button>
</div>
<div class="table-scroll">
<table class="sessions-table">
<thead>
<tr>
<th>Date</th>
<th class="r">Durée</th>
<th class="r">Kills</th>
<th class="r">K/min</th>
<th class="r">Exp</th>
<th class="r">Exp/min</th>
<th class="r">Kamas</th>
<th class="r">Cols/min</th>
<th>Mob principal</th>
<th>Top item</th>
</tr>
</thead>
<tbody id="histSessionsBody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============ ÉCRAN 4 : Drop Board ============ -->
<section class="app-screen" id="appDropBoard" hidden>
<div class="app-subbar">
<input type="text" id="dropBoardFilter" class="filter-input" placeholder="Filtrer les items…" style="min-width:180px;">
<select id="dropBoardMobSelect" class="filter-input"></select>
<span class="community-badge" id="dropBoardCommunityBadge" hidden>🌐 Communauté</span>
</div>
<div id="dropBoardContent"></div>
</section>
</div>
<!-- Modal 1 : confirmation sync -->
<div class="modal-overlay" id="syncModal">
<div class="modal-box">
<h3>🔄 Sync communauté</h3>
<p class="modal-desc">
Tes stats personnelles sont <strong>envoyées</strong> au serveur, et tu reçois en échange
l'<strong>agrégat de tous les joueurs</strong>.<br>
Tes stats locales ne sont <strong>pas écrasées</strong> — un bouton te permet de basculer
entre ta vue perso et la vue communauté.
</p>
<p class="modal-url-label">URL du serveur</p>
<input type="text" id="syncModalUrl" class="modal-url-input" placeholder="http://51.77.195.139:3742">
<div class="modal-files">
<div class="modal-file-row">
<span class="modal-file-arrow">📤</span>
<span class="modal-file-path" id="syncFileSent"></span>
</div>
<div class="modal-file-row">
<span class="modal-file-arrow">📥</span>
<span class="modal-file-path" id="syncFileReceived"></span>
</div>
</div>
<div class="modal-actions">
<button class="btn-ghost" id="syncModalCancel">Annuler</button>
<button class="btn-ghost" id="syncModalReceiveOnly">📥 Recevoir seulement</button>
<button class="btn-ghost accent" id="syncModalConfirm">📤📥 Envoyer + Recevoir</button>
</div>
</div>
</div>
<!-- Modal 2 : avertissement "recevoir seulement" (apparaît par-dessus modal 1) -->
<div class="modal-overlay-2" id="syncReceiveOnlyModal">
<div class="modal-box">
<h3>📥 Recevoir sans envoyer</h3>
<div class="modal-warn">
<strong>La communauté se nourrit des contributions de chacun.</strong>
Si tout le monde reçoit sans envoyer, les stats communauté ne s'améliorent jamais.
Tes données ne sont pas sensibles — c'est juste des chances de drop Minecraft.
</div>
<p class="modal-desc">
Tu peux quand même continuer sans envoyer si tu veux juste consulter les stats
existantes. Mais pense à envoyer les tiennes la prochaine fois !
</p>
<div class="modal-actions">
<button class="btn-ghost" id="syncReceiveOnlyBack">← Revenir et envoyer</button>
<button class="btn-ghost danger" id="syncReceiveOnlyConfirm">Recevoir quand même</button>
</div>
</div>
</div>
<!-- Menu contextuel "qui drop cet item ?" -->
<div class="item-ctx-menu" id="itemCtxMenu">
<div class="item-ctx-title">Qui drop cet item ?</div>
<div id="itemCtxBody"></div>
</div>
<script>
(function(){
"use strict";
// ---------- Décodage des codes couleur Minecraft ----------
const LEGACY = {0:'#000000',1:'#0000AA',2:'#00AA00',3:'#00AAAA',4:'#AA0000',5:'#AA00AA',
6:'#FFAA00',7:'#AAAAAA',8:'#555555',9:'#5555FF',a:'#55FF55',b:'#55FFFF',
c:'#FF5555',d:'#FF55FF',e:'#FFFF55',f:'#FFFFFF'};
function escHtml(c){ return c.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s){ return escHtml(s).replace(/"/g,'&quot;'); }
function mcPlain(raw){
if (!raw) return '';
return raw.replace(/§x(§[0-9A-Fa-f]){6}/g,'').replace(/§./g,'');
}
function mcColorToHtml(raw){
if (!raw) return '';
let i=0, color=null, bold=false, italic=false, html='';
while (i < raw.length){
if (raw[i] === '§' && i+1 < raw.length){
const code = raw[i+1].toLowerCase();
if (code === 'x'){
let hex='', j=i+2;
for (let k=0;k<6 && raw[j]==='§';k++){ hex += raw[j+1]; j+=2; }
if (hex.length === 6) color = '#'+hex;
i = j; continue;
}
if (LEGACY[code]){ color = LEGACY[code]; bold=false; italic=false; i+=2; continue; }
if (code === 'l'){ bold = true; i+=2; continue; }
if (code === 'o'){ italic = true; i+=2; continue; }
if (code === 'r'){ color=null; bold=false; italic=false; i+=2; continue; }
i+=2; continue;
}
let style = '';
if (color) style += 'color:'+color+';';
if (bold) style += 'font-weight:700;';
if (italic) style += 'font-style:italic;';
html += style ? '<span style="'+style+'">'+escHtml(raw[i])+'</span>' : escHtml(raw[i]);
i++;
}
return html;
}
const PALETTE = ['#E0A949','#6B8F5C','#9B5DE5','#5DB7E5','#E0556B','#F2C94C','#5DE5C0','#E58FD8'];
function colorForKey(key){
let h = 0;
for (let i=0;i<key.length;i++) h = (h*31 + key.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
function fmt(n, maxDec){
if (n === undefined || n === null || isNaN(n)) n = 0;
return n.toLocaleString('fr-FR', {maximumFractionDigits: maxDec === undefined ? 0 : maxDec});
}
function generateUUID(){
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
const $ = (id) => document.getElementById(id);
// ======================================================================
// SHELL : navigation entre écrans (dossier -> accueil -> outils)
// ======================================================================
const screens = {
folder: $('folderScreen'),
hub: $('hubScreen'),
farmTracker: $('appFarmTracker'),
dropBoard: $('appDropBoard'),
history: $('appHistory')
};
const homeBtn = $('homeBtn');
const changeFolderBtn = $('changeFolderBtn');
function showScreen(name){
for (const key in screens) screens[key].hidden = (key !== name);
homeBtn.hidden = (name === 'hub' || name === 'folder');
changeFolderBtn.hidden = (name === 'folder');
}
$('brandHome').addEventListener('click', () => {
if (!screens.folder.hidden) return;
showScreen('hub');
});
homeBtn.addEventListener('click', () => showScreen('hub'));
// ---------- Bouton "Mises à jour" ----------
const checkUpdateBtn = $('checkUpdateBtn');
checkUpdateBtn.addEventListener('click', async () => {
checkUpdateBtn.disabled = true;
checkUpdateBtn.textContent = 'Vérification...';
const result = await window.api.checkUpdateManual();
checkUpdateBtn.disabled = false;
checkUpdateBtn.textContent = 'Mises à jour';
if (!result.checked) return;
if (!result.hasUpdate) {
checkUpdateBtn.textContent = 'A jour ✓';
setTimeout(() => { checkUpdateBtn.textContent = 'Mises à jour'; }, 3000);
}
// Si hasUpdate: la dialog native s'est affichée côté main.js
});
$('tileFarmTracker').addEventListener('click', () => showScreen('farmTracker'));
$('tileDropBoard').addEventListener('click', () => { showScreen('dropBoard'); renderDropBoard(); });
$('tileHistory').addEventListener('click', () => { showScreen('history'); renderHistoryScreen(); });
const globalState = { logPath: null, serverUrl: '', clientId: '' };
let sessionHistory = { type: 'farm-tracker-session-history', version: 1, sessions: [] };
function showFolderError(msg){
$('folderErrorBox').hidden = false;
$('folderErrorBox').textContent = msg;
}
function clearFolderError(){
$('folderErrorBox').hidden = true;
$('folderErrorBox').textContent = '';
}
async function pickFolderFlow(isReconfigure){
clearFolderError();
let result;
try{ result = await window.api.pickFolder(); }
catch(err){ showFolderError('Erreur inattendue : ' + err.message); return false; }
if (result.canceled) return false;
if (result.error){ showFolderError(result.error); return false; }
globalState.logPath = result.logPath;
await persistAppState(true);
if (isReconfigure){
await reconfigureFarmTracker(result.logPath);
} else {
await initFarmTracker(result.logPath, true);
}
return true;
}
$('pickFolderBtn').addEventListener('click', async () => {
const ok = await pickFolderFlow(false);
if (ok) showScreen('hub');
});
changeFolderBtn.addEventListener('click', () => pickFolderFlow(true));
// ======================================================================
// Persistance : état général (app-state.json) + stats de drop (dropstats.json)
// ======================================================================
function dropStatsToPlain(dropStatsMap){
const mobs = {};
for (const [mobName, entry] of dropStatsMap.entries()){
const items = {};
for (const [key, it] of entry.items.entries()){
items[key] = {name: mcPlain(it.rawName) || it.name || '', rawName: it.rawName, occurrences: it.occurrences, totalQty: it.totalQty};
}
mobs[mobName] = {kills: entry.kills, items};
}
return {type: 'farm-tracker-dropstats', version: 1, updatedAt: new Date().toISOString(), mobs};
}
function plainToDropStats(plainMobs){
const map = new Map();
for (const [mobName, mobData] of Object.entries(plainMobs || {})){
const items = new Map();
for (const [key, it] of Object.entries(mobData.items || {})){
items.set(key, {rawName: it.rawName || it.name || '', occurrences: it.occurrences || 0, totalQty: it.totalQty || 0});
}
map.set(mobName, {kills: mobData.kills || 0, items});
}
return map;
}
let appStateSaveTimer = null;
function persistAppState(immediate){
if (appStateSaveTimer) clearTimeout(appStateSaveTimer);
const run = async () => {
appStateSaveTimer = null;
if (!globalState.clientId) globalState.clientId = generateUUID();
const obj = {
version: 2,
logPath: globalState.logPath,
serverUrl: globalState.serverUrl,
clientId: globalState.clientId,
farmTracker: ft ? {
mobCounts: Object.fromEntries(ft.state.mobCounts),
itemCounts: Array.from(ft.state.itemCounts.values()),
expTotal: ft.state.expTotal,
killCols: ft.state.killCols,
sellPrices: Object.fromEntries(ft.state.sellPrices),
sessionStart: ft.state.sessionStart,
pausedAccum: ft.state.pausedAccum,
feedEntries: ft.state.feedEntries.slice(0, 50),
savedAt: Date.now()
} : null
};
try{ await window.api.saveAppState(obj); }catch(e){ console.warn('Sauvegarde auto échouée', e); }
};
if (immediate) run(); else appStateSaveTimer = setTimeout(run, 400);
}
let dropStatsSaveTimer = null;
function persistDropStats(immediate){
if (dropStatsSaveTimer) clearTimeout(dropStatsSaveTimer);
const run = async () => {
dropStatsSaveTimer = null;
try{ await window.api.saveDropStats(dropStatsToPlain(ft.state.dropStats)); }
catch(e){ console.warn('Sauvegarde des stats de drop échouée', e); }
};
if (immediate) run(); else dropStatsSaveTimer = setTimeout(run, 400);
}
let communityDropStatsSaveTimer = null;
function persistCommunityDropStats(immediate){
if (communityDropStatsSaveTimer) clearTimeout(communityDropStatsSaveTimer);
const run = async () => {
communityDropStatsSaveTimer = null;
if (!ft || !ft.state.communityDropStats) return;
try{ await window.api.saveCommunityDropStats(dropStatsToPlain(ft.state.communityDropStats)); }
catch(e){ console.warn('Sauvegarde community échouée', e); }
};
if (immediate) run(); else communityDropStatsSaveTimer = setTimeout(run, 400);
}
// ======================================================================
// OUTIL : Farm Tracker
// ======================================================================
let ft = null;
function createFarmTracker(){
const state = {
logPath: null,
lastSize: 0,
carry: '',
pollTimer: null,
clockTimer: null,
sessionStart: null,
lastEventAt: null,
paused: false,
pauseStartedAt: null,
pausedAccum: 0,
mobCounts: new Map(),
itemCounts: new Map(),
expTotal: 0,
killCols: 0,
sellPrices: new Map(),
sellTotal: 0,
dropStats: new Map(), // stats perso (uniquement ton farming)
communityDropStats: null, // agrégat reçu du serveur (null = pas encore syncé)
showCommunity: true, // bascule perso / communauté dans l'affichage
feedEntries: [],
itemRows: new Map(),
mobFilter: '',
itemFilter: '',
selectedDropMob: null,
timeline: [],
timelineTimer: null
};
// Retourne les stats à afficher : communauté si dispo et activé, sinon perso
function getDisplayDropStats(){
if (state.communityDropStats && state.showCommunity) return state.communityDropStats;
return state.dropStats;
}
const pauseBtn = $('pauseBtn');
const resetBtn = $('resetBtn');
const statusDot = $('statusDot');
const statusText = $('statusText');
const itemsList = $('itemsList');
const mobsList = $('mobsList');
const globalMsg = $('globalMsg');
function setStatus(kind, text){
statusDot.className = 'dot' + (kind ? ' '+kind : '');
statusText.textContent = text;
$('tileFarmTrackerStatus').textContent = (kind === 'live' ? '● en direct' : kind === 'paused' ? '● en pause' : kind === 'error' ? '● erreur' : '● en attente');
}
let msgTimer = null;
function flashMessage(text, isError){
globalMsg.textContent = text;
globalMsg.className = 'global-msg show' + (isError ? ' error' : '');
if (msgTimer) clearTimeout(msgTimer);
msgTimer = setTimeout(() => { globalMsg.className = 'global-msg'; }, 5000);
}
function getElapsedMs(){
let accum = state.pausedAccum;
if (state.paused && state.pauseStartedAt) accum += (Date.now() - state.pauseStartedAt);
return Math.max(Date.now() - state.sessionStart - accum, 0);
}
async function start(logPath, resetCounters){
state.logPath = logPath;
if (resetCounters){
state.mobCounts.clear();
state.itemCounts.clear();
state.itemRows.clear();
itemsList.innerHTML = '';
state.feedEntries = [];
state.expTotal = 0;
state.killCols = 0;
state.sellTotal = 0;
state.sessionStart = Date.now();
state.pausedAccum = 0;
}
if (!state.sessionStart) state.sessionStart = Date.now();
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || 0;
state.carry = '';
state.paused = false;
state.pauseStartedAt = null;
setStatus('live', 'En direct — ' + state.logPath);
renderAll();
startPolling();
startClock();
if (resetCounters) state.timeline = [];
startTimelineTimer();
persistAppState(true);
}
pauseBtn.addEventListener('click', async () => {
if (!state.logPath) return;
if (!state.paused){
state.paused = true;
state.pauseStartedAt = Date.now();
stopPolling();
stopTimelineTimer();
setStatus('paused', 'En pause');
pauseBtn.textContent = '▶ Reprendre';
flashMessage('Suivi en pause — le chrono et les cadences sont gelés.');
} else {
state.pausedAccum += Date.now() - state.pauseStartedAt;
state.paused = false;
state.pauseStartedAt = null;
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || state.lastSize;
state.carry = '';
setStatus('live', 'En direct — ' + state.logPath);
pauseBtn.textContent = '⏸ Pause';
startPolling();
startTimelineTimer();
flashMessage('Suivi repris.');
}
});
resetBtn.addEventListener('click', async () => {
if (!state.logPath) return;
await saveCurrentSessionToHistory();
state.mobCounts.clear();
state.itemCounts.clear();
state.itemRows.clear();
itemsList.innerHTML = '';
state.feedEntries = [];
state.expTotal = 0;
state.killCols = 0;
state.sellTotal = 0;
state.sessionStart = Date.now();
state.pausedAccum = 0;
if (state.paused) state.pauseStartedAt = Date.now();
state.lastEventAt = null;
state.timeline = [];
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || 0;
state.carry = '';
$('liveChartsPanel').hidden = true;
stopTimelineTimer();
if (!state.paused) startTimelineTimer();
renderAll();
persistAppState(true);
flashMessage('Session réinitialisée (les chances de drop ne sont pas affectées).');
});
$('resetDropBtn').addEventListener('click', () => {
if (!state.dropStats.size) return;
if (!window.confirm('Réinitialiser tes stats de chance de drop perso ? (les stats communauté ne sont pas affectées)')) return;
state.dropStats.clear();
state.selectedDropMob = null;
renderDropStats();
persistDropStats(true);
flashMessage('Stats de chance de drop perso réinitialisées.');
});
$('exportDropBtn').addEventListener('click', async () => {
const result = await window.api.exportDropStats();
if (result.canceled) return;
flashMessage('Stats exportées : ' + result.filePath);
});
$('importDropBtn').addEventListener('click', async () => {
const result = await window.api.importMergeDropStats();
if (result.canceled) return;
state.dropStats = plainToDropStats(result.dropStats.mobs);
renderDropStats();
const okCount = result.details.filter(d => d.ok).length;
const failCount = result.details.length - okCount;
flashMessage(
'Fusion : ' + okCount + ' fichier(s) ajoutés (+' + fmt(result.totalAdded) + ' kills)' +
(failCount ? ', ' + failCount + ' en erreur' : '') + '.',
failCount > 0 && okCount === 0
);
});
// ---------- Toggle communauté / perso ----------
$('toggleCommunityBtn').addEventListener('click', () => {
state.showCommunity = !state.showCommunity;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
});
// ---------- Sync communautaire ----------
$('syncServerUrl').addEventListener('input', (e) => {
globalState.serverUrl = e.target.value.trim();
});
// Ouvre le modal de confirmation
$('syncDropBtn').addEventListener('click', async () => {
const modalUrl = $('syncModalUrl');
const current = $('syncServerUrl').value.trim() || globalState.serverUrl;
modalUrl.value = current;
// Charge les chemins de fichiers
try {
const paths = await window.api.getFilePaths();
$('syncFileSent').textContent = paths.dropStats;
$('syncFileReceived').textContent = paths.communityDropStats;
} catch(e) {}
$('syncModal').classList.add('visible');
modalUrl.focus();
});
$('syncModalCancel').addEventListener('click', () => {
$('syncModal').classList.remove('visible');
});
$('syncModal').addEventListener('click', (e) => {
if (e.target === $('syncModal')) $('syncModal').classList.remove('visible');
});
// "Recevoir seulement" → ouvre modal 2 par-dessus
$('syncModalReceiveOnly').addEventListener('click', () => {
$('syncReceiveOnlyModal').classList.add('visible');
});
// Modal 2 : revenir au modal 1
$('syncReceiveOnlyBack').addEventListener('click', () => {
$('syncReceiveOnlyModal').classList.remove('visible');
});
// Modal 2 : confirmer recevoir sans envoyer
$('syncReceiveOnlyConfirm').addEventListener('click', async () => {
const serverUrl = $('syncModalUrl').value.trim();
if (!serverUrl) return;
$('syncReceiveOnlyModal').classList.remove('visible');
$('syncModal').classList.remove('visible');
globalState.serverUrl = serverUrl;
$('syncServerUrl').value = serverUrl;
const btn = $('syncDropBtn');
btn.disabled = true;
btn.textContent = '⏳ Récupération…';
const result = await window.api.fetchCommunityStats(serverUrl);
btn.disabled = false;
btn.textContent = '🔄 Sync communauté';
if (!result.ok){
flashMessage('Récupération échouée : ' + (result.error || 'erreur inconnue'), true);
return;
}
state.communityDropStats = plainToDropStats(result.dropStats.mobs);
state.showCommunity = true;
state.selectedDropMob = null;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
persistCommunityDropStats(true);
persistAppState(false);
const n = result.clientCount || '?';
flashMessage('Stats communauté récupérées (' + n + ' joueur' + (n > 1 ? 's' : '') + ').');
});
// Modal 1 : envoyer + recevoir
$('syncModalConfirm').addEventListener('click', async () => {
const serverUrl = $('syncModalUrl').value.trim();
if (!serverUrl) return;
$('syncModal').classList.remove('visible');
globalState.serverUrl = serverUrl;
$('syncServerUrl').value = serverUrl;
if (!globalState.clientId) globalState.clientId = generateUUID();
const btn = $('syncDropBtn');
btn.disabled = true;
btn.textContent = '⏳ Sync…';
const plain = dropStatsToPlain(state.dropStats);
const result = await window.api.syncDropStats(serverUrl, globalState.clientId, plain);
btn.disabled = false;
btn.textContent = '🔄 Sync communauté';
if (!result.ok){
flashMessage('Sync échoué : ' + (result.error || 'erreur inconnue'), true);
return;
}
state.communityDropStats = plainToDropStats(result.dropStats.mobs);
state.showCommunity = true;
state.selectedDropMob = null;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
persistCommunityDropStats(true);
persistAppState(false);
const n = result.clientCount || '?';
flashMessage('Sync OK — ' + n + ' joueur' + (n > 1 ? 's' : '') + ' dans la communauté.');
});
// ---------- Lecture / parsing du log ----------
function tryParseLootLine(line){
const idx = line.indexOf('{"op":"PLAYER_LOOT"');
if (idx === -1) return null;
const jsonStr = line.slice(idx).trim();
try{ return JSON.parse(jsonStr); }catch(e){ return null; }
}
function processEvent(evt){
const data = evt.data || {};
const mobRaw = data.mob || 'Mob inconnu';
const mobPlain = mcPlain(mobRaw) || 'Mob inconnu';
const col = data.col || 0;
const exp = data.exp || 0;
state.mobCounts.set(mobPlain, (state.mobCounts.get(mobPlain) || 0) + 1);
state.expTotal += exp;
state.killCols += col;
if (!state.dropStats.has(mobPlain)) state.dropStats.set(mobPlain, {kills: 0, items: new Map()});
const dropEntry = state.dropStats.get(mobPlain);
dropEntry.kills += 1;
const items = Array.isArray(data.items) ? data.items : [];
const feedItems = [];
for (const it of items){
const material = it.material || '?';
const modeldata = it.modeldata !== undefined ? it.modeldata : '?';
const key = material + ':' + modeldata;
const amt = it.amount || 1;
const existing = state.itemCounts.get(key);
if (existing){ existing.qty += amt; existing.rawName = it.name || existing.rawName; }
else { state.itemCounts.set(key, {rawName: it.name || '', qty: amt, key}); }
const dropItem = dropEntry.items.get(key) || {rawName: '', occurrences: 0, totalQty: 0};
dropItem.occurrences += 1;
dropItem.totalQty += amt;
if (it.name) dropItem.rawName = it.name;
dropEntry.items.set(key, dropItem);
feedItems.push({rawName: it.name || '', qty: amt, key});
}
state.feedEntries.unshift({time: Date.now(), mobRaw, mobPlain, exp, col, items: feedItems});
if (state.feedEntries.length > 50) state.feedEntries.length = 50;
state.lastEventAt = Date.now();
}
async function poll(){
if (!state.logPath || state.paused) return;
const sizeRes = await window.api.getFileSize(state.logPath);
if (sizeRes.error){
setStatus('error', 'Fichier introuvable — change de dossier');
stopPolling();
return;
}
const newSize = sizeRes.size;
if (newSize === state.lastSize) return;
if (newSize < state.lastSize){ state.lastSize = 0; state.carry = ''; }
const chunkRes = await window.api.readChunk(state.logPath, state.lastSize, newSize);
state.lastSize = newSize;
const combined = state.carry + (chunkRes.text || '');
const lines = combined.split('\n');
state.carry = lines.pop();
let changed = false;
for (const line of lines){
if (!line) continue;
const evt = tryParseLootLine(line);
if (evt){ processEvent(evt); changed = true; }
}
if (changed){
renderAll();
persistAppState(false);
persistDropStats(false);
}
}
function startPolling(){ stopPolling(); state.pollTimer = setInterval(poll, 1200); }
function stopPolling(){ if (state.pollTimer){ clearInterval(state.pollTimer); state.pollTimer = null; } }
function startClock(){
if (state.clockTimer) clearInterval(state.clockTimer);
state.clockTimer = setInterval(updateClock, 1000);
updateClock();
}
function pad(n){ return String(n).padStart(2,'0'); }
// ---------- Timeline / graphes en direct ----------
function snapshotTimeline(){
if (!state.sessionStart || state.paused) return;
const totalKills = sumValues(state.mobCounts);
state.timeline.push({ t: Date.now(), kills: totalKills, exp: state.expTotal, cols: state.killCols });
if (state.timeline.length > 180) state.timeline.shift();
renderLiveCharts();
}
function startTimelineTimer(){
stopTimelineTimer();
snapshotTimeline();
state.timelineTimer = setInterval(snapshotTimeline, 30000);
}
function stopTimelineTimer(){
if (state.timelineTimer){ clearInterval(state.timelineTimer); state.timelineTimer = null; }
}
function drawLiveChart(container, rates, colorHex){
if (rates.length < 2){
container.innerHTML = '<p class="chart-empty" style="font-size:11px;padding:6px 0;">En attente…</p>';
return;
}
const W=400,H=55,padL=4,padR=4,padT=6,padB=4;
const plotW=W-padL-padR,plotH=H-padT-padB,n=rates.length;
const maxV=Math.max(...rates,0.01);
const xs=rates.map((_,i)=>padL+(n===1?plotW/2:(i/(n-1))*plotW));
const ys=rates.map(v=>padT+plotH-(Math.max(0,v)/maxV)*plotH);
const linePath=xs.map((x,i)=>(i===0?'M':'L')+x.toFixed(1)+','+ys[i].toFixed(1)).join(' ');
const areaPath=linePath+' L'+xs[n-1].toFixed(1)+','+(padT+plotH)+' L'+xs[0].toFixed(1)+','+(padT+plotH)+' Z';
const grid=[0.5,1.0].map(pct=>{
const y=(padT+plotH*(1-pct)).toFixed(1);
return '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="#26301F" stroke-width="1"/>';
}).join('');
container.innerHTML=
'<svg viewBox="0 0 '+W+' '+H+'" width="100%" height="'+H+'" xmlns="http://www.w3.org/2000/svg">'
+grid
+'<path d="'+areaPath+'" fill="'+colorHex+'" fill-opacity="0.13"/>'
+'<path d="'+linePath+'" stroke="'+colorHex+'" fill="none" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
+'</svg>';
}
function fmtHHMM(ts){ const d=new Date(ts); return pad(d.getHours())+':'+pad(d.getMinutes()); }
function renderLiveCharts(){
const snap = state.timeline;
const panel = $('liveChartsPanel');
if (snap.length < 1){ panel.hidden = true; return; }
panel.hidden = false;
const killRates=[], expRates=[], colRates=[];
for (let i=1; i<snap.length; i++){
const dt=(snap[i].t-snap[i-1].t)/60000;
if (dt<=0) continue;
killRates.push((snap[i].kills-snap[i-1].kills)/dt);
expRates.push((snap[i].exp-snap[i-1].exp)/dt);
colRates.push((snap[i].cols-snap[i-1].cols)/dt);
}
drawLiveChart($('liveChartKills'), killRates, '#6B8F5C');
drawLiveChart($('liveChartExp'), expRates, '#F2C94C');
drawLiveChart($('liveChartCols'), colRates, '#C97B4A');
const last = arr => arr.length ? arr[arr.length-1] : 0;
$('liveChartKillsVal').textContent = fmt(last(killRates),1)+'/min';
$('liveChartExpVal').textContent = fmt(last(expRates),1)+'/min';
$('liveChartColsVal').textContent = fmt(last(colRates),1)+'/min';
if (snap.length >= 2){
const t1=fmtHHMM(snap[0].t), t2=fmtHHMM(snap[snap.length-1].t);
['KillsTime','ExpTime','ColsTime'].forEach(id=>{
const el=$('liveChart'+id);
if (el) el.innerHTML='<span>'+t1+'</span><span>'+t2+'</span>';
});
}
}
function sumValues(map){ let t=0; for (const v of map.values()) t+=v; return t; }
async function saveCurrentSessionToHistory(){
const totalKills=sumValues(state.mobCounts);
if (!state.sessionStart||totalKills===0) return;
const durationMs=getElapsedMs();
const topItems=Array.from(state.itemCounts.values())
.sort((a,b)=>b.qty-a.qty).slice(0,5)
.map(it=>({key:it.key,name:mcPlain(it.rawName)||'Item inconnu',qty:it.qty}));
let topMob='',topMobCount=0;
for (const [name,count] of state.mobCounts){
if (count>topMobCount){topMob=name;topMobCount=count;}
}
sessionHistory.sessions.push({
id:generateUUID(),
startedAt:state.sessionStart,
endedAt:Date.now(),
durationMs,
totalKills,
totalExp:Math.round(state.expTotal),
killCols:Math.round(state.killCols),
sellTotal:Math.round(state.sellTotal*100)/100,
topMob,
mobCounts:Object.fromEntries(state.mobCounts),
topItems
});
try{
await window.api.saveSessionHistory(sessionHistory);
renderHistoryTileStatus();
}catch(e){console.warn('Sauvegarde historique échouée',e);}
}
function updateClock(){
if (!state.sessionStart) return;
const elapsedMs = getElapsedMs();
const elapsedSec = Math.floor(elapsedMs/1000);
const h = Math.floor(elapsedSec/3600), m = Math.floor((elapsedSec%3600)/60), s = elapsedSec%60;
$('timer').textContent = pad(h)+':'+pad(m)+':'+pad(s);
const elapsedMin = Math.max(elapsedSec/60, 1/60);
const totalKills = sumValues(state.mobCounts);
$('killsRate').textContent = fmt(totalKills/elapsedMin,1) + ' / min';
$('expRate').textContent = fmt(state.expTotal/elapsedMin,1) + ' / min';
$('colsKillRate').textContent = fmt(state.killCols/elapsedMin,1) + ' / min';
$('colsSellRate').textContent = fmt(state.sellTotal/elapsedMin,1) + ' / min';
if (state.lastEventAt){
const secAgo = Math.floor((Date.now()-state.lastEventAt)/1000);
$('lastEvent').textContent = secAgo < 3 ? 'loot à l\'instant' : 'dernier loot il y a ' + secAgo + 's';
}
}
function renderAll(){
renderHeroCounts();
renderMobs();
renderItemsTable();
renderDropStats();
renderFeed();
if (!$('appDropBoard').hidden) renderDropBoard();
}
function renderHeroCounts(){
const totalKills = sumValues(state.mobCounts);
$('totalKills').textContent = fmt(totalKills);
$('expTotal').textContent = fmt(Math.round(state.expTotal));
$('colsKill').textContent = fmt(Math.round(state.killCols));
$('colsSell').textContent = '≈ ' + fmt(state.sellTotal,2);
}
function renderMobs(){
mobsList.innerHTML = '';
const allEntries = Array.from(state.mobCounts.entries()).sort((a,b) => b[1]-a[1]);
if (!allEntries.length){ mobsList.innerHTML = '<p class="feed-empty">Aucun mob encore looté.</p>'; return; }
const maxMob = allEntries[0][1];
const filter = state.mobFilter;
const filtered = filter ? allEntries.filter(([name]) => name.toLowerCase().includes(filter)) : allEntries;
if (!filtered.length){ mobsList.innerHTML = '<p class="feed-empty">Aucun mob ne correspond au filtre.</p>'; return; }
for (const [name, count] of filtered){
const color = colorForKey(name);
const row = document.createElement('div');
row.className = 'bar-row';
row.innerHTML =
'<div class="bar-label"><span>'+escHtml(name)+'</span><span class="bar-count">'+fmt(count)+'</span></div>'+
'<div class="bar-track"><div class="bar-fill" style="width:'+(count/maxMob*100)+'%;background:'+color+'"></div></div>';
mobsList.appendChild(row);
}
}
$('mobFilterInput').addEventListener('input', (e) => { state.mobFilter = e.target.value.toLowerCase(); renderMobs(); });
function renderItemsTable(){
const itemEntries = Array.from(state.itemCounts.values()).sort((a,b) => b.qty-a.qty);
if (!itemEntries.length){
itemsList.innerHTML = '<tr><td colspan="5" class="feed-empty">Aucun item encore looté.</td></tr>';
state.itemRows.clear();
updateSellValues();
return;
}
if (itemsList.querySelector('.feed-empty')){ itemsList.innerHTML = ''; state.itemRows.clear(); }
const elapsedMin = Math.max(getElapsedMs()/60000, 1/60);
const seenKeys = new Set();
for (const item of itemEntries){
seenKeys.add(item.key);
const color = colorForKey(item.key);
const nameHtml = mcColorToHtml(item.rawName) || '<span>Item inconnu</span>';
let refs = state.itemRows.get(item.key);
if (!refs){
const tr = document.createElement('tr');
tr.dataset.key = item.key;
tr.title = 'Clic droit pour voir qui drop cet item';
tr.innerHTML =
'<td class="name-cell"><span class="swatch" style="background:'+color+'"></span><span class="name-html"></span></td>'+
'<td class="qty-cell"></td>'+
'<td class="rate-cell"></td>'+
'<td class="price-cell"><input type="number" class="price-input" min="0" step="0.01" placeholder="0"></td>'+
'<td class="value-cell"></td>';
itemsList.appendChild(tr);
refs = {
tr,
nameEl: tr.querySelector('.name-html'),
qtyEl: tr.querySelector('.qty-cell'),
rateEl: tr.querySelector('.rate-cell'),
priceInput: tr.querySelector('.price-input'),
valueEl: tr.querySelector('.value-cell'),
plainName: ''
};
const existingPrice = state.sellPrices.get(item.key);
if (existingPrice !== undefined) refs.priceInput.value = existingPrice;
refs.priceInput.addEventListener('input', () => {
const val = parseFloat(refs.priceInput.value);
state.sellPrices.set(item.key, isNaN(val) ? 0 : val);
updateSellValues();
persistAppState(false);
});
state.itemRows.set(item.key, refs);
}
refs.nameEl.innerHTML = nameHtml;
refs.qtyEl.textContent = '×' + fmt(item.qty);
refs.rateEl.textContent = fmt(item.qty/elapsedMin,1) + '/min';
refs.plainName = mcPlain(item.rawName).toLowerCase();
refs.tr.dataset.qty = item.qty;
itemsList.appendChild(refs.tr);
}
for (const key of Array.from(state.itemRows.keys())){
if (!seenKeys.has(key)){
const refs = state.itemRows.get(key);
refs.tr.remove();
state.itemRows.delete(key);
}
}
applyItemFilter();
updateSellValues();
}
function applyItemFilter(){
const filter = state.itemFilter;
for (const refs of state.itemRows.values()){
const match = !filter || refs.plainName.includes(filter);
refs.tr.style.display = match ? '' : 'none';
}
}
$('itemFilterInput').addEventListener('input', (e) => { state.itemFilter = e.target.value.toLowerCase(); applyItemFilter(); });
function updateSellValues(){
let total = 0;
for (const [key, refs] of state.itemRows.entries()){
const qty = parseFloat(refs.tr.dataset.qty) || 0;
const price = state.sellPrices.get(key) || 0;
const value = qty * price;
total += value;
refs.valueEl.textContent = fmt(value,2);
}
state.sellTotal = total;
$('sellTotalCell').textContent = fmt(total,2);
$('colsSell').textContent = '≈ ' + fmt(total,2);
}
function renderDropStats(){
const select = $('dropMobSelect');
const body = $('dropStatsBody');
const badge = $('communityBadge');
const toggleBtn = $('toggleCommunityBtn');
const hasCommunity = !!state.communityDropStats;
const usingCommunity = hasCommunity && state.showCommunity;
if (badge) badge.hidden = !usingCommunity;
if (toggleBtn){
toggleBtn.hidden = !hasCommunity;
toggleBtn.textContent = usingCommunity ? '👤 Mes stats perso' : '🌐 Stats communauté';
}
const displayStats = getDisplayDropStats();
if (!displayStats.size){
select.innerHTML = '';
select.disabled = true;
body.innerHTML = '<p class="feed-empty">Pas encore de données. Continue à farmer, ou importe des fichiers d\'autres joueurs.</p>';
return;
}
select.disabled = false;
const mobs = Array.from(displayStats.entries()).sort((a,b) => b[1].kills - a[1].kills);
select.innerHTML = mobs.map(([name, entry]) =>
'<option value="'+escAttr(name)+'">'+escHtml(name)+' (n='+fmt(entry.kills)+')</option>'
).join('');
const stillValid = state.selectedDropMob && mobs.some(([n]) => n === state.selectedDropMob);
const selection = stillValid ? state.selectedDropMob : mobs[0][0];
state.selectedDropMob = selection;
select.value = selection;
const entry = displayStats.get(selection);
const kills = entry.kills;
body.innerHTML = '';
if (!entry.items.size){
body.innerHTML = '<p class="feed-empty">Pas encore de drop enregistré pour ce mob.</p>';
return;
}
const itemEntries = Array.from(entry.items.entries()).sort((a,b) => (b[1].occurrences/kills) - (a[1].occurrences/kills));
for (const [key, it] of itemEntries){
const pct = kills > 0 ? (it.occurrences/kills*100) : 0;
const avgQty = it.occurrences > 0 ? it.totalQty/it.occurrences : 0;
const color = colorForKey(key);
const nameHtml = mcColorToHtml(it.rawName) || '<span>Item inconnu</span>';
const row = document.createElement('div');
row.className = 'droprow';
row.dataset.itemKey = key;
row.title = 'Clic droit pour voir tous les mobs qui droppent cet item';
row.innerHTML =
'<div class="droprow-label"><span>'+nameHtml+'</span><span class="droprow-pct">'+fmt(pct,1)+' %</span></div>'+
'<div class="bar-track"><div class="bar-fill" style="width:'+Math.min(pct,100)+'%;background:'+color+'"></div></div>'+
'<div class="droprow-sub">'+fmt(it.occurrences)+'/'+fmt(kills)+' kills · ~'+fmt(avgQty,1)+' par drop</div>';
body.appendChild(row);
}
}
$('dropMobSelect').addEventListener('change', (e) => { state.selectedDropMob = e.target.value; renderDropStats(); });
function renderFeed(){
const feed = $('feed');
if (!state.feedEntries.length){
feed.innerHTML = '<p class="feed-empty">Les nouveaux drops apparaîtront ici…</p>';
return;
}
feed.innerHTML = '';
for (const entry of state.feedEntries.slice(0, 40)){
const d = new Date(entry.time);
const timeStr = pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
const itemsHtml = entry.items.map(it =>
'<span style="border-left:2px solid '+colorForKey(it.key)+';padding-left:6px;">×'+it.qty+' '+(mcColorToHtml(it.rawName)||'?')+'</span>'
).join('');
const tagsParts = [];
if (entry.exp) tagsParts.push('+'+entry.exp+' exp');
if (entry.col) tagsParts.push('+'+entry.col+' col');
const tags = tagsParts.length ? ' · '+tagsParts.join(' · ') : '';
const div = document.createElement('div');
div.className = 'feed-item';
div.style.borderLeftColor = colorForKey(entry.mobPlain);
div.innerHTML =
'<span class="feed-time">'+timeStr+'</span>'+
'<span class="feed-items">'+(itemsHtml || '<span>—</span>')+'</span>'+
'<span class="feed-mob">'+mcColorToHtml(entry.mobRaw)+tags+'</span>';
feed.appendChild(div);
}
}
function restoreFrom(saved, dropStatsPlain){
if (saved){
state.mobCounts = new Map(Object.entries(saved.mobCounts || {}));
state.itemCounts = new Map((saved.itemCounts || []).map(it => [it.key, it]));
state.expTotal = saved.expTotal || 0;
state.killCols = saved.killCols || 0;
state.sellPrices = new Map(Object.entries(saved.sellPrices || {}));
state.sessionStart = saved.sessionStart || null;
state.pausedAccum = saved.pausedAccum || 0;
state.feedEntries = saved.feedEntries || [];
if (saved.savedAt && state.sessionStart){
const gap = Date.now() - saved.savedAt;
if (gap > 0) state.pausedAccum += gap;
}
}
if (dropStatsPlain) state.dropStats = plainToDropStats(dropStatsPlain.mobs);
}
return { state, start, restoreFrom, getElapsedMs, getDisplayDropStats };
}
// ======================================================================
// DROP BOARD
// ======================================================================
function getDropBoardStats(){
if (!ft) return null;
if (ft.state.communityDropStats && ft.state.showCommunity) return ft.state.communityDropStats;
return ft.state.dropStats;
}
function updateDropBoardMobSelect(){
const select = $('dropBoardMobSelect');
if (!select) return;
const currentVal = select.value;
const allOpt = '<option value="__all__">Tous les mobs</option>';
const stats = getDropBoardStats();
if (!stats || !stats.size){
select.innerHTML = allOpt;
select.disabled = true;
return;
}
select.disabled = false;
const mobs = Array.from(stats.entries()).sort((a,b) => b[1].kills - a[1].kills);
select.innerHTML = allOpt + mobs.map(([name, entry]) =>
'<option value="'+escAttr(name)+'">'+escHtml(name)+' ('+fmt(entry.kills)+')</option>'
).join('');
if ([...select.options].some(o => o.value === currentVal)) select.value = currentVal;
}
function renderDropBoard(){
const content = $('dropBoardContent');
if (!content) return;
const stats = getDropBoardStats();
const usingCommunity = ft && !!ft.state.communityDropStats && ft.state.showCommunity;
const badge = $('dropBoardCommunityBadge');
if (badge) badge.hidden = !usingCommunity;
if (stats && stats.size){
let totalKills = 0;
for (const [,e] of stats) totalKills += e.kills;
const tile = $('tileDropBoardStatus');
if (tile) tile.textContent = '● ' + stats.size + ' mob' + (stats.size > 1 ? 's' : '') + ' · ' + fmt(totalKills) + ' kills';
}
if (!stats || !stats.size){
updateDropBoardMobSelect();
content.innerHTML = '<p class="feed-empty" style="padding:24px 0;">Pas encore de données. Lance Farm Tracker et commence à farmer, ou importe des fichiers.</p>';
return;
}
updateDropBoardMobSelect();
const filterVal = ($('dropBoardFilter') ? $('dropBoardFilter').value : '').toLowerCase();
const mobVal = $('dropBoardMobSelect') ? $('dropBoardMobSelect').value : '__all__';
const board = document.createElement('div');
board.className = 'drop-board';
let mobs = Array.from(stats.entries()).sort((a,b) => b[1].kills - a[1].kills);
if (mobVal !== '__all__') mobs = mobs.filter(([name]) => name === mobVal);
for (const [mobName, entry] of mobs){
if (!entry.items.size) continue;
let items = Array.from(entry.items.entries());
if (filterVal) items = items.filter(([, it]) => mcPlain(it.rawName).toLowerCase().includes(filterVal));
if (!items.length) continue;
items.sort((a,b) => (b[1].occurrences / entry.kills) - (a[1].occurrences / entry.kills));
const section = document.createElement('div');
section.className = 'drop-board-mob';
const header = document.createElement('div');
header.className = 'drop-board-mob-header';
header.innerHTML =
'<span class="drop-board-mob-name">'+escHtml(mobName)+'</span>'+
'<span class="drop-board-mob-kills">'+fmt(entry.kills)+' kills</span>';
section.appendChild(header);
const cardsGrid = document.createElement('div');
cardsGrid.className = 'drop-board-cards';
for (const [key, it] of items){
const pct = entry.kills > 0 ? (it.occurrences / entry.kills * 100) : 0;
const avgQty = it.occurrences > 0 ? it.totalQty / it.occurrences : 0;
const color = colorForKey(key);
const nameHtml = mcColorToHtml(it.rawName) || '<span>Item inconnu</span>';
let pctColor = 'var(--text-dim)';
if (pct >= 50) pctColor = 'var(--gold)';
else if (pct >= 20) pctColor = 'var(--copper)';
else if (pct >= 5) pctColor = 'var(--moss)';
const card = document.createElement('div');
card.className = 'drop-card';
card.style.borderLeftColor = color;
card.dataset.itemKey = key;
card.title = 'Clic droit pour voir tous les mobs qui droppent cet item';
card.innerHTML =
'<div class="drop-card-pct" style="color:'+pctColor+'">'+fmt(pct,1)+' %</div>'+
'<div class="drop-card-name">'+nameHtml+'</div>'+
'<div class="drop-card-sub">'+fmt(it.occurrences)+'/'+fmt(entry.kills)+' kills · ~'+fmt(avgQty,1)+'/drop</div>';
cardsGrid.appendChild(card);
}
section.appendChild(cardsGrid);
board.appendChild(section);
}
if (!board.children.length){
content.innerHTML = '<p class="feed-empty" style="padding:24px 0;">Aucun résultat pour ce filtre.</p>';
} else {
content.innerHTML = '';
content.appendChild(board);
}
}
$('dropBoardFilter').addEventListener('input', renderDropBoard);
$('dropBoardMobSelect').addEventListener('change', renderDropBoard);
// ======================================================================
// HISTORIQUE DES SESSIONS
// ======================================================================
function fmtShort(v){
if (v>=1000000) return (v/1000000).toFixed(v>=10000000?0:1).replace('.',',')+'M';
if (v>=1000) return (v/1000).toFixed(v>=10000?0:1).replace('.',',')+'k';
return Math.round(v).toLocaleString('fr-FR');
}
function renderSvgChart(container, sessions, getValue, colorHex, labelFn, labelContainer){
const data=sessions.map(getValue);
if (data.length<2){
container.innerHTML='<p class="chart-empty">Pas assez de données (min. 2 sessions).</p>';
if (labelContainer) labelContainer.innerHTML='';
return;
}
const W=600,H=80,padL=8,padR=8,padT=10,padB=8;
const plotW=W-padL-padR,plotH=H-padT-padB,n=data.length;
const maxV=Math.max(...data,1);
const xs=data.map((_,i)=>padL+(n===1?plotW/2:(i/(n-1))*plotW));
const ys=data.map(v=>padT+plotH-(Math.max(0,v)/maxV)*plotH);
const linePath=xs.map((x,i)=>(i===0?'M':'L')+x.toFixed(1)+','+ys[i].toFixed(1)).join(' ');
const areaPath=linePath+' L'+xs[n-1].toFixed(1)+','+(padT+plotH)+' L'+xs[0].toFixed(1)+','+(padT+plotH)+' Z';
const gridLines=[0.25,0.5,0.75,1.0].map(pct=>{
const y=(padT+plotH*(1-pct)).toFixed(1);
return '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="#26301F" stroke-width="1"/>';
}).join('');
const r=n>30?2:3;
const dots=xs.map((x,i)=>{
const val=data[i];
const d=new Date(sessions[i].startedAt);
const dateStr=d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'});
const valStr=labelFn?labelFn(val):Math.round(val).toLocaleString('fr-FR');
const title=escHtml('Session '+(i+1)+' ('+dateStr+') : '+valStr);
return '<circle cx="'+x.toFixed(1)+'" cy="'+ys[i].toFixed(1)+'" r="'+r+'" fill="'+colorHex+'" stroke="#0B0F0C" stroke-width="1.5"><title>'+title+'</title></circle>';
}).join('');
container.innerHTML=
'<svg viewBox="0 0 '+W+' '+H+'" width="100%" height="'+H+'" xmlns="http://www.w3.org/2000/svg">'
+gridLines
+'<path d="'+areaPath+'" fill="'+colorHex+'" fill-opacity="0.10"/>'
+'<path d="'+linePath+'" stroke="'+colorHex+'" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+dots
+'</svg>';
if (labelContainer&&sessions.length>=2){
const d1=new Date(sessions[0].startedAt);
const d2=new Date(sessions[sessions.length-1].startedAt);
const s1=d1.toLocaleDateString('fr-FR',{day:'2-digit',month:'short'});
const s2=d2.toLocaleDateString('fr-FR',{day:'2-digit',month:'short'});
labelContainer.innerHTML='<span>'+escHtml(s1)+'</span><span>'+escHtml(s2)+'</span>';
}
}
function renderHistoryTileStatus(){
const n=sessionHistory.sessions.length;
const el=$('tileHistoryStatus');
if (el) el.textContent='● '+n+' session'+(n>1?'s':'');
}
function renderHistoryScreen(){
const sessions=sessionHistory.sessions;
renderHistoryTileStatus();
const histHero=$('histHero');
const histEmpty=$('histEmptyState');
const histCharts=$('histChartsArea');
const histPanel=$('histSessionsPanel');
if (!sessions.length){
histHero.hidden=true; histEmpty.hidden=false; histCharts.hidden=true; histPanel.hidden=true;
return;
}
histEmpty.hidden=true; histHero.hidden=false; histCharts.hidden=false; histPanel.hidden=false;
const totalKills=sessions.reduce((s,x)=>s+x.totalKills,0);
const totalMs=sessions.reduce((s,x)=>s+x.durationMs,0);
const totalExp=sessions.reduce((s,x)=>s+(x.totalExp||0),0);
const totalKamas=sessions.reduce((s,x)=>s+(x.sellTotal||0),0);
const avgKills=Math.round(totalKills/sessions.length);
const avgMs=totalMs/sessions.length;
const avgExp=Math.round(totalExp/sessions.length);
const avgKamas=Math.round(totalKamas/sessions.length);
const ratesPerMin=sessions.filter(s=>s.durationMs>0).map(s=>s.totalKills/(s.durationMs/60000));
const avgRate=ratesPerMin.length?ratesPerMin.reduce((a,b)=>a+b,0)/ratesPerMin.length:0;
const bestRate=ratesPerMin.length?Math.max(...ratesPerMin):0;
$('histTotalSessions').textContent=fmt(sessions.length);
$('histTotalKills').textContent=fmt(totalKills);
$('histAvgKills').textContent='moy. '+fmt(avgKills)+' / session';
const tH=Math.floor(totalMs/3600000),tM=Math.floor((totalMs%3600000)/60000);
$('histTotalTime').textContent=tH+'h '+String(tM).padStart(2,'0')+'m';
const aH=Math.floor(avgMs/3600000),aM=Math.floor((avgMs%3600000)/60000);
$('histAvgTime').textContent='moy. '+(aH>0?aH+'h ':'')+String(aM).padStart(2,'0')+'m / session';
$('histAvgRate').textContent=fmt(avgRate,1)+'/min';
$('histBestRate').textContent='meilleur '+fmt(bestRate,1)+'/min';
$('histTotalExp').textContent=fmtShort(totalExp);
$('histAvgExp').textContent='moy. '+fmtShort(avgExp)+' / session';
$('histTotalKamas').textContent=fmtShort(totalKamas);
$('histAvgKamas').textContent='moy. '+fmtShort(avgKamas)+' / session';
const kAvgEl=$('histKillsAvgLabel');
if (kAvgEl) kAvgEl.textContent='moy. '+fmt(avgKills)+' kills';
renderSvgChart($('chartKills'),sessions,s=>s.totalKills,'#9B5DE5',null,$('chartKillsLabels'));
renderSvgChart($('chartRate'),sessions,s=>{
const min=s.durationMs/60000; return min>0?Math.round(s.totalKills/min*10)/10:0;
},'#5DB7E5',v=>fmt(v,1)+'/min',$('chartRateLabels'));
renderSvgChart($('chartExp'),sessions,s=>s.totalExp,'#F2C94C',v=>fmtShort(v),$('chartExpLabels'));
const tbody=$('histSessionsBody');
tbody.innerHTML='';
const sorted=[...sessions].reverse();
for (const s of sorted){
const d=new Date(s.startedAt);
const dateStr=d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'2-digit'})
+' '+d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
const dH=Math.floor(s.durationMs/3600000),dM=Math.floor((s.durationMs%3600000)/60000);
const durStr=dH>0?dH+'h'+String(dM).padStart(2,'0')+'m':dM+'min';
const minElapsed=s.durationMs/60000;
const killsPerMin=minElapsed>0?Math.round(s.totalKills/minElapsed*10)/10:0;
const expPerMin=minElapsed>0?Math.round(s.totalExp/minElapsed):0;
const colsPerMin=minElapsed>0?Math.round((s.killCols||0)/minElapsed*10)/10:0;
const topItemName=s.topItems&&s.topItems.length?s.topItems[0].name+' ×'+s.topItems[0].qty:'—';
const tr=document.createElement('tr');
tr.innerHTML=
'<td>'+escHtml(dateStr)+'</td>'+
'<td class="r mono">'+escHtml(durStr)+'</td>'+
'<td class="r hl">'+fmt(s.totalKills)+'</td>'+
'<td class="r mono">'+fmt(killsPerMin,1)+'/min</td>'+
'<td class="r mono">'+fmtShort(s.totalExp)+'</td>'+
'<td class="r mono">'+fmtShort(expPerMin)+'/min</td>'+
'<td class="r mono">'+(s.sellTotal?fmtShort(Math.round(s.sellTotal)):'—')+'</td>'+
'<td class="r mono">'+fmt(colsPerMin,1)+'/min</td>'+
'<td class="dim">'+escHtml(s.topMob||'—')+'</td>'+
'<td class="dim" title="'+escHtml(topItemName)+'">'+escHtml(topItemName.length>22?topItemName.slice(0,20)+'…':topItemName)+'</td>';
tbody.appendChild(tr);
}
}
$('clearHistoryBtn').addEventListener('click', async () => {
if (!sessionHistory.sessions.length) return;
if (!window.confirm('Effacer tout l\'historique des sessions ? Cette action est irréversible.')) return;
sessionHistory.sessions=[];
try{ await window.api.saveSessionHistory(sessionHistory); }catch(e){}
renderHistoryScreen();
});
async function initFarmTracker(logPath, resetCounters){
if (!ft) ft = createFarmTracker();
await ft.start(logPath, resetCounters);
}
async function reconfigureFarmTracker(newLogPath){
if (!ft) { await initFarmTracker(newLogPath, true); return; }
await ft.start(newLogPath, true);
}
// ======================================================================
// Menu contextuel : "qui drop cet item ?"
// ======================================================================
const itemCtxMenu = $('itemCtxMenu');
const itemCtxBody = $('itemCtxBody');
function hideCtxMenu(){
itemCtxMenu.classList.remove('visible');
}
function showItemDroppers(itemKey, clientX, clientY){
const stats = ft ? (ft.state.communityDropStats && ft.state.showCommunity ? ft.state.communityDropStats : ft.state.dropStats) : null;
if (!stats || !stats.size){ hideCtxMenu(); return; }
const droppers = [];
for (const [mobName, entry] of stats){
const it = entry.items.get(itemKey);
if (!it) continue;
const pct = entry.kills > 0 ? it.occurrences / entry.kills * 100 : 0;
droppers.push({
mobName,
pct,
avgQty: it.occurrences > 0 ? it.totalQty / it.occurrences : 0,
occ: it.occurrences,
kills: entry.kills
});
}
if (!droppers.length){
itemCtxBody.innerHTML = '<p class="item-ctx-empty">Aucun mob connu pour cet item.</p>';
} else {
droppers.sort((a, b) => b.pct - a.pct);
itemCtxBody.innerHTML = droppers.map(d => {
let pctColor = 'var(--text-dim)';
if (d.pct >= 50) pctColor = 'var(--gold)';
else if (d.pct >= 20) pctColor = 'var(--copper)';
else if (d.pct >= 5) pctColor = 'var(--moss)';
return '<div class="item-ctx-row">'+
'<div class="item-ctx-mob">'+
'<span class="item-ctx-mob-name">'+escHtml(d.mobName)+'</span>'+
'<span class="item-ctx-mob-pct" style="color:'+pctColor+'">'+fmt(d.pct,1)+' %</span>'+
'</div>'+
'<div class="item-ctx-mob-sub">'+fmt(d.occ)+'/'+fmt(d.kills)+' kills · ~'+fmt(d.avgQty,1)+'/drop</div>'+
'</div>';
}).join('');
}
itemCtxMenu.classList.add('visible');
// Positionne sans déborder de l'écran
const mW = 280, mH = Math.min(droppers.length * 58 + 38, 420);
itemCtxMenu.style.left = Math.min(clientX, window.innerWidth - mW - 8) + 'px';
itemCtxMenu.style.top = Math.min(clientY, window.innerHeight - mH - 8) + 'px';
}
document.addEventListener('contextmenu', e => {
// Ne pas intercepter le clic droit sur les inputs (paste, etc.)
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA'){
hideCtxMenu(); return;
}
const target = e.target.closest('[data-item-key],[data-key]');
if (!target){ hideCtxMenu(); return; }
const key = target.dataset.itemKey || target.dataset.key;
if (!key){ hideCtxMenu(); return; }
e.preventDefault();
showItemDroppers(key, e.clientX, e.clientY);
});
document.addEventListener('click', hideCtxMenu);
document.addEventListener('keydown', e => { if (e.key === 'Escape') hideCtxMenu(); });
// ======================================================================
// Démarrage : restauration automatique + reconnexion silencieuse
// ======================================================================
(async () => {
let appState = null, dropStatsPlain = null, communityDropStatsPlain = null;
try{ appState = await window.api.loadAppState(); }catch(e){}
try{ dropStatsPlain = await window.api.loadDropStats(); }catch(e){}
try{ communityDropStatsPlain = await window.api.loadCommunityDropStats(); }catch(e){}
try{ const h=await window.api.loadSessionHistory(); if (h&&h.sessions) sessionHistory=h; }catch(e){}
renderHistoryTileStatus();
if (appState && appState.logPath){
let check = {ok: false};
try{ check = await window.api.checkLogPath(appState.logPath); }catch(e){}
if (check.ok){
globalState.logPath = appState.logPath;
globalState.serverUrl = appState.serverUrl || '';
globalState.clientId = appState.clientId || generateUUID();
ft = createFarmTracker();
ft.restoreFrom(appState.farmTracker, dropStatsPlain);
if (communityDropStatsPlain && communityDropStatsPlain.mobs){
ft.state.communityDropStats = plainToDropStats(communityDropStatsPlain.mobs);
}
await ft.start(appState.logPath, false);
// Restore sync URL input
const urlInput = $('syncServerUrl');
if (urlInput && globalState.serverUrl) urlInput.value = globalState.serverUrl;
showScreen('hub');
return;
}
}
showScreen('folder');
})();
})();
// ---------- Progression de mise à jour ----------
if (window.api && window.api.onUpdateProgress) {
const overlay = document.createElement('div');
overlay.id = 'updateOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(11,15,12,0.94);z-index:9999;display:none;align-items:center;justify-content:center;flex-direction:column;gap:18px;';
overlay.innerHTML =
'<div style="font-family:var(--font-mono);color:var(--text);font-size:14px;letter-spacing:.04em;" id="updateOverlayText">Téléchargement de la mise à jour…</div>' +
'<div style="width:320px;height:5px;background:var(--bg-panel-2);border:1px solid var(--border);">' +
'<div id="updateProgressBar" style="height:100%;background:var(--corrupt);width:0%;transition:width .25s;"></div>' +
'</div>' +
'<div style="font-family:var(--font-mono);color:var(--text-dim);font-size:12px;" id="updateOverlayPct">0%</div>';
document.body.appendChild(overlay);
window.api.onUpdateProgress(({ percent, status, error }) => {
const bar = document.getElementById('updateProgressBar');
const pct = document.getElementById('updateOverlayPct');
const txt = document.getElementById('updateOverlayText');
if (status === 'error') {
overlay.style.display = 'none';
return;
}
overlay.style.display = 'flex';
bar.style.width = percent + '%';
pct.textContent = percent + '%';
txt.textContent = status === 'installing' ? 'Installation… l\'application va redémarrer.' : 'Téléchargement de la mise à jour…';
});
}
</script>
</body>
</html>