@@ -290,7 +290,45 @@
. sessions-table td . hl { font-family : var ( - - font - mono ) ; color : var ( - - text ) ; }
. sessions-table td . dim { font-size : 12 px ; color : var ( - - text - dim ) ; }
. hist-empty { padding : 40 px 0 ; text-align : center ; color : var ( - - text - dim ) ; font-size : 14 px ; line-height : 1.8 ; }
. sessions-table tbody tr : hover td { background : rgba ( 255 , 255 , 255 , .03 ) ; }
@ media ( max-width : 760px ) { . charts-2col { grid-template-columns : 1 fr ; } }
/* ---------- Graphes en direct ---------- */
. charts-3col { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ) ; gap : 14 px ; }
@ media ( max-width : 760px ) { . charts-3col { grid-template-columns : 1 fr ; } }
. live-chart-header { display : flex ; justify-content : space-between ; align-items : baseline ;
font-family : var ( - - font - mono ) ; font-size : 11 px ; margin-bottom : 5 px ; }
. live-chart-header . chart-label { color : var ( - - text - dim ) ; }
. live-chart-header . chart-val { font-size : 12 px ; font-weight : 600 ; }
/* ---------- Timers ---------- */
. timer-add-row { display : flex ; gap : 8 px ; align-items : center ; flex-wrap : wrap ; margin-bottom : 4 px ; }
. timer-add-row input { background : var ( - - bg - panel -2 ) ; border : 1 px solid var ( - - border ) ; color : var ( - - text ) ;
font-family : var ( - - font - body ) ; font-size : 13 px ; padding : 7 px 10 px ; min-width : 0 ; }
. timer-add-row input : focus { outline : 2 px solid var ( - - gold ) ; outline-offset : 0 ; }
. timer-add-row input :: placeholder { color : var ( - - text - dim ) ; }
. timer-config-list { display : flex ; flex-direction : column ; gap : 6 px ; margin-top : 12 px ; }
. timer-config-row { display : flex ; align-items : center ; gap : 10 px ; padding : 8 px 10 px ;
background : var ( - - bg - panel -2 ) ; border : 1 px solid var ( - - border ) ; }
. timer-config-mob { flex : 1 ; font-size : 13 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. timer-config-dur { font-family : var ( - - font - mono ) ; font-size : 12 px ; color : var ( - - text - dim ) ; white-space : nowrap ; }
. timer-active-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 190 px , 1 fr ) ) ; gap : 10 px ; margin-top : 12 px ; }
. timer-card { background : var ( - - bg - panel -2 ) ; border : 2 px solid var ( - - border ) ; padding : 14 px 16 px ; }
. timer-card . timer-ok { border-color : var ( - - moss ) ; }
. timer-card . timer-warn { border-color : var ( - - paused ) ; }
. timer-card . timer-done { border-color : var ( - - boss ) ; background : rgba ( 224 , 85 , 107 , .07 ) ; }
. timer-card-mob { font-size : 13 px ; font-weight : 600 ; margin-bottom : 8 px ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. timer-card-countdown { font-family : var ( - - font - mono ) ; font-size : 32 px ; font-weight : 700 ; line-height : 1 ; letter-spacing : .04 em ; }
. timer-card-countdown . timer-ok { color : var ( - - moss ) ; }
. timer-card-countdown . timer-warn { color : var ( - - paused ) ; }
. timer-card-countdown . timer-done { color : var ( - - boss ) ; }
. timer-card-sub { font-size : 11 px ; color : var ( - - text - dim ) ; margin-top : 5 px ; }
. timer-card-bar { height : 3 px ; background : var ( - - border ) ; margin-top : 10 px ; overflow : hidden ; }
. timer-card-bar-fill { height : 100 % ; transition : width .9 s linear ; }
. timer-empty-msg { color : var ( - - text - dim ) ; font-size : 13 px ; padding : 20 px 0 ; }
# chartTip { position : fixed ; z-index : 1001 ; background : var ( - - bg - panel ) ; border : 1 px solid var ( - - border ) ;
padding : 4 px 10 px ; font-family : var ( - - font - mono ) ; font-size : 11 px ; color : var ( - - text ) ;
pointer-events : none ; display : none ; white-space : nowrap ; box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , .5 ) ; }
< / style >
< / head >
< body >
@@ -348,6 +386,12 @@
< 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" > ● 0 session< / span >
< / button >
< button class = "app-tile" id = "tileTimers" >
< span class = "app-tile-icon" > ⏱< / span >
< span class = "app-tile-title" > Timers< / span >
< span class = "app-tile-desc" > Compte à rebours automatique au kill — idéal pour les respawns de boss.< / span >
< span class = "app-tile-status" id = "tileTimersStatus" > ● 0 timer< / span >
< / button >
< / div >
< / section >
@@ -394,6 +438,38 @@
< / 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 >
@@ -488,6 +564,21 @@
< 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" >
@@ -512,7 +603,7 @@
< div class = "charts-2col" style = "margin-bottom:14px;" >
< div class = "panel" >
< h2 > Kills / heure < / h2 >
< h2 > Kills / min < / h2 >
< div class = "chart-wrap" id = "chartRate" > < / div >
< div class = "chart-label-row" id = "chartRateLabels" > < / div >
< / div >
@@ -537,10 +628,13 @@
< th > Date< / th >
< th class = "r" > Durée< / th >
< th class = "r" > Kills< / th >
< th class = "r" > Kills/h < / th >
< th class = "r" > K/min < / th >
< th class = "r" > Exp< / th >
< th class = "r" > Cols kill < / 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 >
@@ -550,6 +644,47 @@
< / section >
<!-- ============ ÉCRAN 6 : Détail session ============ -->
< section class = "app-screen" id = "appSessionDetail" hidden >
< div style = "display:flex;align-items:center;gap:12px;margin-bottom:4px;" >
< button class = "btn-ghost small" id = "sessionDetailBackBtn" > ← Historique< / button >
< span id = "sessionDetailTitle" style = "font-size:13px;color:var(--text-dim);" > < / span >
< / div >
< div class = "hero" id = "sessionDetailHero" >
< div class = "hero-stat" >
< p class = "eyebrow" > Durée< / p >
< p class = "hero-number" id = "sdDur" > —< / p >
< / div >
< div class = "hero-stat" >
< p class = "eyebrow" > Kills< / p >
< p class = "hero-number" id = "sdKills" > —< / p >
< p class = "hero-sub" id = "sdKillsRate" > —< / p >
< / div >
< div class = "hero-stat" >
< p class = "eyebrow" > Exp< / p >
< p class = "hero-number" id = "sdExp" > —< / p >
< p class = "hero-sub" id = "sdExpRate" > —< / p >
< / div >
< div class = "hero-stat" >
< p class = "eyebrow" > Kamas< / p >
< p class = "hero-number" id = "sdKamas" > —< / p >
< p class = "hero-sub" id = "sdCols" > —< / p >
< / div >
< / div >
< div class = "charts-2col" id = "sessionDetailPanels" >
< div class = "panel" >
< h2 > Mobs tués< / h2 >
< div id = "sdMobList" class = "bars" style = "max-height:none;" > < / div >
< / div >
< div class = "panel" >
< h2 > Top items lootés< / h2 >
< div id = "sdItemList" class = "bars" style = "max-height:none;" > < / div >
< / div >
< / div >
< / section >
<!-- ============ ÉCRAN 4 : Drop Board ============ -->
< section class = "app-screen" id = "appDropBoard" hidden >
@@ -563,6 +698,46 @@
< / section >
<!-- ============ ÉCRAN 5 : Timers ============ -->
< section class = "app-screen" id = "appTimers" hidden >
< div class = "panel" style = "margin-bottom:14px;" >
< div class = "panel-head-row" >
< h2 > Ajouter un timer< / h2 >
< / div >
< div class = "timer-add-row" >
< input type = "text" id = "timerMobInput" placeholder = "Nom du mob (ex: Skeleton)" style = "flex:2;" list = "timerMobSuggestions" autocomplete = "off" >
< datalist id = "timerMobSuggestions" > < / datalist >
< input type = "text" id = "timerDurInput" placeholder = "Durée (ex: 5:00 ou 90)" style = "flex:1;max-width:130px;" >
< button class = "btn-ghost" id = "timerAddBtn" > Ajouter< / button >
< / div >
< div id = "timerConfigList" class = "timer-config-list" > < / div >
< / div >
< div class = "panel" >
< div class = "panel-head-row" >
< h2 > Comptes à rebours actifs< / h2 >
< div class = "spacer" > < / div >
< span style = "font-family:var(--font-mono);font-size:10px;color:var(--text-dim);" > se déclenchent automatiquement au kill< / span >
< / div >
< div id = "timerActiveGrid" class = "timer-active-grid" > < / div >
< / div >
< div class = "panel" >
< div class = "panel-head-row" >
< h2 > Alertes de drop < span class = "muted" > — son au drop d'un item< / span > < / h2 >
< / div >
< div class = "timer-add-row" >
< input type = "text" id = "alertItemInput" placeholder = "Nom de l'item (ex: Diamond)" style = "flex:2;" list = "alertItemSuggestions" autocomplete = "off" >
< datalist id = "alertItemSuggestions" > < / datalist >
< button class = "btn-ghost" id = "alertAddBtn" > Ajouter< / button >
< / div >
< p style = "font-size:11.5px;color:var(--text-dim);margin:8px 0 4px;" > Un ping discret est joué à chaque fois que cet item drop.< / p >
< div id = "alertConfigList" class = "timer-config-list" > < / div >
< / div >
< / section >
< / div >
<!-- Modal 1 : confirmation sync -->
@@ -615,6 +790,8 @@
< / div >
< / div >
< div id = "chartTip" > < / div >
<!-- Menu contextuel "qui drop cet item ?" -->
< div class = "item-ctx-menu" id = "itemCtxMenu" >
< div class = "item-ctx-title" > Qui drop cet item ?< / div >
@@ -683,6 +860,28 @@
const $ = ( id ) => document . getElementById ( id ) ;
// ---------- Tooltip pour les graphes ----------
const chartTip = $ ( 'chartTip' ) ;
function showChartTip ( text , cx , cy ) {
chartTip . textContent = text ;
chartTip . style . display = 'block' ;
chartTip . style . left = Math . min ( cx + 14 , window . innerWidth - 170 ) + 'px' ;
chartTip . style . top = Math . min ( cy - 30 , window . innerHeight - 44 ) + 'px' ;
}
function hideChartTip ( ) { chartTip . style . display = 'none' ; }
function attachChartTip ( svg ) {
if ( ! svg ) return ;
svg . addEventListener ( 'mouseover' , e => { if ( e . target . dataset . tip ) showChartTip ( e . target . dataset . tip , e . clientX , e . clientY ) ; } ) ;
svg . addEventListener ( 'mouseout' , e => { if ( e . target . dataset . tip ) hideChartTip ( ) ; } ) ;
svg . addEventListener ( 'mousemove' , e => {
if ( e . target . dataset . tip ) {
chartTip . style . left = Math . min ( e . clientX + 14 , window . innerWidth - 170 ) + 'px' ;
chartTip . style . top = Math . min ( e . clientY - 30 , window . innerHeight - 44 ) + 'px' ;
}
} ) ;
svg . addEventListener ( 'mouseleave' , hideChartTip ) ;
}
// ======================================================================
// SHELL : navigation entre écrans (dossier -> accueil -> outils)
// ======================================================================
@@ -692,7 +891,9 @@
hub : $ ( 'hubScreen' ) ,
farmTracker : $ ( 'appFarmTracker' ) ,
dropBoard : $ ( 'appDropBoard' ) ,
history : $ ( 'appHistory' )
history : $ ( 'appHistory' ) ,
timers : $ ( 'appTimers' ) ,
sessionDetail : $ ( 'appSessionDetail' )
} ;
const homeBtn = $ ( 'homeBtn' ) ;
const changeFolderBtn = $ ( 'changeFolderBtn' ) ;
@@ -728,6 +929,7 @@
$ ( 'tileFarmTracker' ) . addEventListener ( 'click' , ( ) => showScreen ( 'farmTracker' ) ) ;
$ ( 'tileDropBoard' ) . addEventListener ( 'click' , ( ) => { showScreen ( 'dropBoard' ) ; renderDropBoard ( ) ; } ) ;
$ ( 'tileHistory' ) . addEventListener ( 'click' , ( ) => { showScreen ( 'history' ) ; renderHistoryScreen ( ) ; } ) ;
$ ( 'tileTimers' ) . addEventListener ( 'click' , ( ) => { showScreen ( 'timers' ) ; renderTimerScreen ( ) ; } ) ;
const globalState = { logPath : null , serverUrl : '' , clientId : '' } ;
let sessionHistory = { type : 'farm-tracker-session-history' , version : 1 , sessions : [ ] } ;
@@ -777,7 +979,7 @@
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 } ;
mobs [ mobName ] = { kills : entry . kills , totalExp : entry . totalExp || 0 , totalCols : entry . totalCols || 0 , items } ;
}
return { type : 'farm-tracker-dropstats' , version : 1 , updatedAt : new Date ( ) . toISOString ( ) , mobs } ;
}
@@ -788,7 +990,7 @@
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 } ) ;
map . set ( mobName , { kills : mobData . kills || 0 , totalExp : mobData . totalExp || 0 , totalCols : mobData . totalCols || 0 , items } ) ;
}
return map ;
}
@@ -875,7 +1077,9 @@
itemRows : new Map ( ) ,
mobFilter : '' ,
itemFilter : '' ,
selectedDropMob : null
selectedDropMob : null ,
timeline : [ ] ,
timelineTimer : null
} ;
// Retourne les stats à afficher : communauté si dispo et activé, sinon perso
@@ -936,6 +1140,8 @@
renderAll ( ) ;
startPolling ( ) ;
startClock ( ) ;
if ( resetCounters ) state . timeline = [ ] ;
startTimelineTimer ( ) ;
persistAppState ( true ) ;
}
@@ -945,6 +1151,7 @@
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.' ) ;
@@ -958,6 +1165,7 @@
setStatus ( 'live' , 'En direct — ' + state . logPath ) ;
pauseBtn . textContent = '⏸ Pause' ;
startPolling ( ) ;
startTimelineTimer ( ) ;
flashMessage ( 'Suivi repris.' ) ;
}
} ) ;
@@ -977,9 +1185,13 @@
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).' ) ;
@@ -1150,10 +1362,13 @@
state . mobCounts . set ( mobPlain , ( state . mobCounts . get ( mobPlain ) || 0 ) + 1 ) ;
state . expTotal += exp ;
state . killCols += col ;
triggerTimer ( mobPlain ) ;
if ( ! state . dropStats . has ( mobPlain ) ) state . dropStats . set ( mobPlain , { kills : 0 , items : new Map ( ) } ) ;
if ( ! state . dropStats . has ( mobPlain ) ) state . dropStats . set ( mobPlain , { kills : 0 , totalExp : 0 , totalCols : 0 , items : new Map ( ) } ) ;
const dropEntry = state . dropStats . get ( mobPlain ) ;
dropEntry . kills += 1 ;
dropEntry . totalExp = ( dropEntry . totalExp || 0 ) + exp ;
dropEntry . totalCols = ( dropEntry . totalCols || 0 ) + col ;
const items = Array . isArray ( data . items ) ? data . items : [ ] ;
const feedItems = [ ] ;
@@ -1174,6 +1389,12 @@
dropEntry . items . set ( key , dropItem ) ;
feedItems . push ( { rawName : it . name || '' , qty : amt , key } ) ;
if ( alertConfigs . length ) {
const pn = mcPlain ( it . name || '' ) . toLowerCase ( ) ;
for ( const alert of alertConfigs ) {
if ( pn && alert . nameLower && pn . includes ( alert . nameLower ) ) { playAlertPing ( ) ; break ; }
}
}
}
state . feedEntries . unshift ( { time : Date . now ( ) , mobRaw , mobPlain , exp , col , items : feedItems } ) ;
@@ -1222,6 +1443,90 @@
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 , unit ) {
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 ( '' ) ;
const dots = xs . map ( ( x , i ) => {
const label = fmt ( rates [ i ] , 1 ) + ( unit ? ' ' + unit : '' ) ;
return '<circle cx="' + x . toFixed ( 1 ) + '" cy="' + ys [ i ] . toFixed ( 1 ) + '" r="2" fill="' + colorHex + '" opacity="0.8"/>'
+ '<circle cx="' + x . toFixed ( 1 ) + '" cy="' + ys [ i ] . toFixed ( 1 ) + '" r="12" fill="transparent" data-tip="' + escAttr ( label ) + '"/>' ;
} ) . 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"/>'
+ dots
+ '</svg>' ;
attachChartTip ( container . querySelector ( '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' , 'kills/min' ) ;
drawLiveChart ( $ ( 'liveChartExp' ) , expRates , '#F2C94C' , 'xp/min' ) ;
drawLiveChart ( $ ( 'liveChartCols' ) , colRates , '#C97B4A' , 'cols/min' ) ;
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 ( ) {
@@ -1446,6 +1751,19 @@
body . innerHTML = '<p class="feed-empty">Pas encore de drop enregistré pour ce mob.</p>' ;
return ;
}
const totalExp = entry . totalExp || 0 ;
const totalCols = entry . totalCols || 0 ;
const statDiv = document . createElement ( 'div' ) ;
statDiv . style . cssText = 'font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;gap:18px;flex-wrap:wrap;' ;
if ( totalExp > 0 ) {
statDiv . innerHTML += '<span><span style="color:var(--gold)">XP</span> ~' + fmt ( totalExp / kills , 1 ) + ' / kill · ' + fmt ( Math . round ( totalExp ) ) + ' total</span>' ;
} else {
statDiv . innerHTML += '<span><span style="color:var(--gold)">XP</span> <span style="opacity:.5">— non disponible</span></span>' ;
}
if ( totalCols > 0 ) {
statDiv . innerHTML += '<span><span style="color:var(--copper)">Cols</span> ~' + fmt ( totalCols / kills , 2 ) + ' / kill · ' + fmt ( Math . round ( totalCols ) ) + ' total</span>' ;
}
body . appendChild ( statDiv ) ;
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 ;
@@ -1588,9 +1906,12 @@
const header = document . createElement ( 'div' ) ;
header . className = 'drop-board-mob-header' ;
const xpPerKill = entry . kills > 0 && ( entry . totalExp || 0 ) > 0 ? fmt ( ( entry . totalExp || 0 ) / entry . kills , 1 ) + ' xp/kill' : null ;
const colPerKill = entry . kills > 0 && ( entry . totalCols || 0 ) > 0 ? fmt ( ( entry . totalCols || 0 ) / entry . kills , 2 ) + ' col/kill' : null ;
const xpColParts = [ xpPerKill , colPerKill ] . filter ( Boolean ) . join ( ' · ' ) ;
header . innerHTML =
'<span class="drop-board-mob-name">' + escHtml ( mobName ) + '</span>' +
'<span class="drop-board-mob-kills">' + fmt ( entry . kills ) + ' kills</span>' ;
'<span class="drop-board-mob-kills">' + fmt ( entry . kills ) + ' kills' + ( xpColParts ? ' · ' + xpColParts : '' ) + ' </span>' ;
section . appendChild ( header ) ;
const cardsGrid = document . createElement ( 'div' ) ;
@@ -1668,8 +1989,9 @@
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>' ;
const tip = escAttr ( '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"/>'
+ '<circle cx="' + x . toFixed ( 1 ) + '" cy="' + ys [ i ] . toFixed ( 1 ) + '" r="10" fill="transparent" data-tip="' + tip + '"/>' ;
} ) . join ( '' ) ;
container . innerHTML =
'<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '" xmlns="http://www.w3.org/2000/svg">'
@@ -1678,6 +2000,7 @@
+ '<path d="' + linePath + '" stroke="' + colorHex + '" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+ dots
+ '</svg>' ;
attachChartTip ( container . querySelector ( 'svg' ) ) ;
if ( labelContainer && sessions . length >= 2 ) {
const d1 = new Date ( sessions [ 0 ] . startedAt ) ;
const d2 = new Date ( sessions [ sessions . length - 1 ] . startedAt ) ;
@@ -1707,8 +2030,15 @@
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' ;
@@ -1716,12 +2046,18 @@
$ ( '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 h = s . durationMs / 360 0000; return h > 0 ? Math . round ( s . totalKills / h ) : 0 ;
} , '#5DB7E5' , v => fmtShort ( v ) + '/h ' , $ ( 'chartRateLabels' ) ) ;
const min = s . durationMs / 6 0000; 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 = '' ;
@@ -1732,20 +2068,102 @@
+ ' ' + 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 killsPerH = s . durationMs > 0 ? Math . round ( s . totalKills / ( s . durationMs / 3600000 ) ) : 0;
const minElapsed = s . durationMs / 6000 0;
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 . style . cursor = 'pointer' ;
tr . title = 'Clic pour voir le détail' ;
tr . dataset . sessionId = s . id ;
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 ( killsPerH ) + '/h </td>' +
'<td class="r mono">' + fmt ( s . totalExp ) + '</td>' +
'<td class="r mono">' + fmt( s . killCols ) + '</td>' +
'<td class="dim ">' + escHtml ( s . topMob || '—' ) + '</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>' +
'<td style="color:var(--text-dim);font-size:11px;padding-left:8px;">→</td>' ;
tbody . appendChild ( tr ) ;
}
}
function renderSessionDetail ( s ) {
const d = new Date ( s . startedAt ) ;
const dateStr = d . toLocaleDateString ( 'fr-FR' , { weekday : 'long' , day : '2-digit' , month : 'long' , year : 'numeric' } )
+ ' à ' + d . toLocaleTimeString ( 'fr-FR' , { hour : '2-digit' , minute : '2-digit' } ) ;
$ ( 'sessionDetailTitle' ) . textContent = dateStr ;
const minElapsed = s . durationMs / 60000 ;
const dH = Math . floor ( s . durationMs / 3600000 ) , dM = Math . floor ( ( s . durationMs % 3600000 ) / 60000 ) , dS = Math . floor ( ( s . durationMs % 60000 ) / 1000 ) ;
$ ( 'sdDur' ) . textContent = dH > 0 ? dH + 'h' + String ( dM ) . padStart ( 2 , '0' ) + 'm' : dM + 'min ' + String ( dS ) . padStart ( 2 , '0' ) + 's' ;
$ ( 'sdKills' ) . textContent = fmt ( s . totalKills ) ;
$ ( 'sdKillsRate' ) . textContent = minElapsed > 0 ? fmt ( s . totalKills / minElapsed , 1 ) + ' kills/min' : '—' ;
$ ( 'sdExp' ) . textContent = fmtShort ( s . totalExp || 0 ) ;
$ ( 'sdExpRate' ) . textContent = minElapsed > 0 ? fmtShort ( Math . round ( ( s . totalExp || 0 ) / minElapsed ) ) + '/min' : '—' ;
$ ( 'sdKamas' ) . textContent = s . sellTotal ? fmtShort ( Math . round ( s . sellTotal ) ) : '—' ;
$ ( 'sdCols' ) . textContent = minElapsed > 0 ? fmt ( ( s . killCols || 0 ) / minElapsed , 1 ) + ' cols/min' : '—' ;
// Mobs
const mobEl = $ ( 'sdMobList' ) ;
const mobEntries = Object . entries ( s . mobCounts || { } ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) ;
if ( ! mobEntries . length ) {
mobEl . innerHTML = '<p style="color:var(--text-dim);font-size:13px;">Aucun mob enregistré.</p>' ;
} else {
const maxCount = mobEntries [ 0 ] [ 1 ] ;
const colors = [ '#9B5DE5' , '#5DB7E5' , '#F2C94C' , '#E05563' , '#5DE5A0' , '#E5955D' ] ;
mobEl . innerHTML = mobEntries . map ( ( [ name , count ] , i ) => {
const pct = ( count / maxCount * 100 ) . toFixed ( 1 ) ;
const color = colors [ i % colors . length ] ;
const perMin = minElapsed > 0 ? ( count / minElapsed ) . toFixed ( 1 ) : '—' ;
return '<div class="bar-row">' +
'<div class="bar-label"><span>' + escHtml ( name ) + '</span>' +
'<span class="bar-count">' + fmt ( count ) + ' <span style="color:var(--text-dim);font-size:11px;">(' + perMin + '/min)</span></span></div>' +
'<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>' +
'</div>' ;
} ) . join ( '' ) ;
}
// Items
const itemEl = $ ( 'sdItemList' ) ;
const items = s . topItems || [ ] ;
if ( ! items . length ) {
itemEl . innerHTML = '<p style="color:var(--text-dim);font-size:13px;">Aucun item enregistré.</p>' ;
} else {
const maxQty = items [ 0 ] . qty ;
const colors = [ '#F2C94C' , '#9B5DE5' , '#5DB7E5' , '#E05563' , '#5DE5A0' ] ;
itemEl . innerHTML = items . map ( ( { name , qty } , i ) => {
const pct = ( qty / maxQty * 100 ) . toFixed ( 1 ) ;
const color = colors [ i % colors . length ] ;
const perMin = minElapsed > 0 ? ( qty / minElapsed ) . toFixed ( 2 ) : '—' ;
return '<div class="bar-row">' +
'<div class="bar-label"><span>' + escHtml ( name ) + '</span>' +
'<span class="bar-count">× ' + fmt ( qty ) + ' <span style="color:var(--text-dim);font-size:11px;">(' + perMin + '/min)</span></span></div>' +
'<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + '"></div></div>' +
'</div>' ;
} ) . join ( '' ) ;
}
}
$ ( 'sessionDetailBackBtn' ) . addEventListener ( 'click' , ( ) => {
showScreen ( 'history' ) ;
renderHistoryScreen ( ) ;
} ) ;
$ ( 'histSessionsBody' ) . addEventListener ( 'click' , e => {
const tr = e . target . closest ( 'tr[data-session-id]' ) ;
if ( ! tr ) return ;
const session = sessionHistory . sessions . find ( s => s . id === tr . dataset . sessionId ) ;
if ( ! session ) return ;
renderSessionDetail ( session ) ;
showScreen ( 'sessionDetail' ) ;
} ) ;
$ ( 'clearHistoryBtn' ) . addEventListener ( 'click' , async ( ) => {
if ( ! sessionHistory . sessions . length ) return ;
if ( ! window . confirm ( 'Effacer tout l\'historique des sessions ? Cette action est irréversible.' ) ) return ;
@@ -1754,6 +2172,228 @@
renderHistoryScreen ( ) ;
} ) ;
// ======================================================================
// TIMERS
// ======================================================================
let timerConfigs = JSON . parse ( localStorage . getItem ( 'timerConfigs' ) || '[]' ) ;
let alertConfigs = JSON . parse ( localStorage . getItem ( 'alertConfigs' ) || '[]' ) ;
function saveAlertConfigs ( ) {
localStorage . setItem ( 'alertConfigs' , JSON . stringify ( alertConfigs ) ) ;
}
let _alertAudio = null ;
function playAlertPing ( ) {
try {
if ( ! _alertAudio ) _alertAudio = new Audio ( 'levelup.ogg' ) ;
_alertAudio . currentTime = 0 ;
_alertAudio . play ( ) . catch ( e => console . warn ( 'Alert ping failed' , e ) ) ;
} catch ( e ) { console . warn ( 'Alert ping failed' , e ) ; }
}
// activeTimers : Map<configId, { endsAt: number, durationSec: number, mobName: string }>
const activeTimers = new Map ( ) ;
let timerClockId = null ;
function saveTimerConfigs ( ) {
localStorage . setItem ( 'timerConfigs' , JSON . stringify ( timerConfigs ) ) ;
updateTimerTileStatus ( ) ;
}
function parseDuration ( str ) {
str = str . trim ( ) ;
if ( str . includes ( ':' ) ) {
const parts = str . split ( ':' ) ;
const m = parseInt ( parts [ 0 ] , 10 ) || 0 ;
const s = parseInt ( parts [ 1 ] , 10 ) || 0 ;
return m * 60 + s ;
}
return parseInt ( str , 10 ) || 0 ;
}
function fmtCountdown ( sec ) {
if ( sec <= 0 ) return 'PRÊT' ;
const m = Math . floor ( sec / 60 ) , s = sec % 60 ;
return String ( m ) . padStart ( 2 , '0' ) + ':' + String ( s ) . padStart ( 2 , '0' ) ;
}
function updateTimerTileStatus ( ) {
const el = $ ( 'tileTimersStatus' ) ;
if ( el ) el . textContent = '● ' + timerConfigs . length + ' timer' + ( timerConfigs . length !== 1 ? 's' : '' ) ;
}
function triggerTimer ( mobPlain ) {
for ( const cfg of timerConfigs ) {
if ( cfg . mobName . toLowerCase ( ) === mobPlain . toLowerCase ( ) ) {
const endsAt = Date . now ( ) + cfg . durationSec * 1000 ;
activeTimers . set ( cfg . id , { endsAt , durationSec : cfg . durationSec , mobName : cfg . mobName } ) ;
renderTimerActiveGrid ( ) ;
}
}
}
function startTimerClock ( ) {
if ( timerClockId ) return ;
timerClockId = setInterval ( ( ) => {
if ( activeTimers . size > 0 ) renderTimerActiveGrid ( ) ;
} , 1000 ) ;
}
function renderTimerActiveGrid ( ) {
const grid = $ ( 'timerActiveGrid' ) ;
if ( ! grid ) return ;
if ( activeTimers . size === 0 ) {
grid . innerHTML = '<p class="timer-empty-msg">Aucun timer actif — tue un mob configuré pour démarrer un compte à rebours.</p>' ;
return ;
}
const now = Date . now ( ) ;
let html = '' ;
for ( const [ id , timer ] of activeTimers ) {
const remaining = Math . max ( 0 , Math . ceil ( ( timer . endsAt - now ) / 1000 ) ) ;
const pct = Math . max ( 0 , remaining / timer . durationSec * 100 ) ;
const cls = remaining <= 0 ? 'timer-done' : pct < 25 ? 'timer-warn' : 'timer-ok' ;
const barColor = remaining <= 0 ? 'var(--boss)' : pct < 25 ? 'var(--paused)' : 'var(--moss)' ;
html +=
'<div class="timer-card ' + cls + '" data-timer-id="' + escHtml ( id ) + '">' +
'<div class="timer-card-mob">' + escHtml ( timer . mobName ) + '</div>' +
'<div class="timer-card-countdown ' + cls + '">' + fmtCountdown ( remaining ) + '</div>' +
'<div class="timer-card-sub">' + ( remaining <= 0 ? 'Respawn disponible !' : 'reste ' + fmtCountdown ( remaining ) ) + '</div>' +
'<div class="timer-card-bar"><div class="timer-card-bar-fill" style="width:' + pct . toFixed ( 1 ) + '%;background:' + barColor + ';"></div></div>' +
'</div>' ;
}
grid . innerHTML = html ;
}
function renderTimerConfigList ( ) {
const list = $ ( 'timerConfigList' ) ;
if ( ! list ) return ;
if ( ! timerConfigs . length ) { list . innerHTML = '' ; return ; }
list . innerHTML = timerConfigs . map ( cfg => {
const m = Math . floor ( cfg . durationSec / 60 ) , s = cfg . durationSec % 60 ;
const durStr = m > 0 ? m + 'min' + ( s > 0 ? ' ' + s + 's' : '' ) : s + 's' ;
return '<div class="timer-config-row">' +
'<span class="timer-config-mob">' + escHtml ( cfg . mobName ) + '</span>' +
'<span class="timer-config-dur">' + escHtml ( durStr ) + '</span>' +
'<button class="btn-ghost small danger" data-del-timer="' + escHtml ( cfg . id ) + '">✕</button>' +
'</div>' ;
} ) . join ( '' ) ;
}
function populateTimerMobSuggestions ( ) {
const dl = $ ( 'timerMobSuggestions' ) ;
if ( ! dl ) return ;
const seen = new Set ( ) ;
// mobs de la session courante
if ( ft && ft . state && ft . state . mobCounts ) {
for ( const name of ft . state . mobCounts . keys ( ) ) seen . add ( name ) ;
}
// mobs de l'historique
for ( const sess of sessionHistory . sessions ) {
if ( sess . mobCounts ) for ( const name of Object . keys ( sess . mobCounts ) ) seen . add ( name ) ;
}
// exclure ceux déjà configurés
const configured = new Set ( timerConfigs . map ( c => c . mobName . toLowerCase ( ) ) ) ;
dl . innerHTML = Array . from ( seen )
. filter ( n => ! configured . has ( n . toLowerCase ( ) ) )
. sort ( ( a , b ) => a . localeCompare ( b ) )
. map ( n => '<option value="' + escHtml ( n ) + '"></option>' )
. join ( '' ) ;
}
function renderTimerScreen ( ) {
renderTimerConfigList ( ) ;
renderTimerActiveGrid ( ) ;
populateTimerMobSuggestions ( ) ;
renderAlertConfigList ( ) ;
populateAlertItemSuggestions ( ) ;
startTimerClock ( ) ;
}
$ ( 'timerAddBtn' ) . addEventListener ( 'click' , ( ) => {
const mobName = $ ( 'timerMobInput' ) . value . trim ( ) ;
const durationSec = parseDuration ( $ ( 'timerDurInput' ) . value ) ;
if ( ! mobName || durationSec <= 0 ) return ;
timerConfigs . push ( { id : generateUUID ( ) , mobName , durationSec } ) ;
saveTimerConfigs ( ) ;
$ ( 'timerMobInput' ) . value = '' ;
$ ( 'timerDurInput' ) . value = '' ;
renderTimerConfigList ( ) ;
populateTimerMobSuggestions ( ) ;
} ) ;
$ ( 'timerMobInput' ) . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' ) $ ( 'timerAddBtn' ) . click ( ) ; } ) ;
$ ( 'timerDurInput' ) . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' ) $ ( 'timerAddBtn' ) . click ( ) ; } ) ;
$ ( 'timerConfigList' ) . addEventListener ( 'click' , e => {
const id = e . target . dataset . delTimer ;
if ( ! id ) return ;
timerConfigs = timerConfigs . filter ( c => c . id !== id ) ;
activeTimers . delete ( id ) ;
saveTimerConfigs ( ) ;
renderTimerConfigList ( ) ;
renderTimerActiveGrid ( ) ;
populateTimerMobSuggestions ( ) ;
} ) ;
function renderAlertConfigList ( ) {
const list = $ ( 'alertConfigList' ) ;
if ( ! list ) return ;
if ( ! alertConfigs . length ) { list . innerHTML = '' ; return ; }
list . innerHTML = alertConfigs . map ( cfg =>
'<div class="timer-config-row">' +
'<span class="timer-config-mob">' + escHtml ( cfg . itemName ) + '</span>' +
'<button class="btn-ghost small danger" data-del-alert="' + escHtml ( cfg . id ) + '">✕</button>' +
'</div>'
) . join ( '' ) ;
}
function populateAlertItemSuggestions ( ) {
const dl = $ ( 'alertItemSuggestions' ) ;
if ( ! dl ) return ;
const seen = new Set ( ) ;
if ( ft && ft . state && ft . state . itemCounts ) {
for ( const it of ft . state . itemCounts . values ( ) ) {
const n = mcPlain ( it . rawName ) ; if ( n ) seen . add ( n ) ;
}
}
if ( ft && ft . state && ft . state . dropStats ) {
for ( const [ , entry ] of ft . state . dropStats ) {
for ( const [ , it ] of entry . items ) { const n = mcPlain ( it . rawName ) ; if ( n ) seen . add ( n ) ; }
}
}
const configured = new Set ( alertConfigs . map ( c => c . itemName . toLowerCase ( ) ) ) ;
dl . innerHTML = Array . from ( seen )
. filter ( n => ! configured . has ( n . toLowerCase ( ) ) )
. sort ( ( a , b ) => a . localeCompare ( b ) )
. map ( n => '<option value="' + escHtml ( n ) + '"></option>' )
. join ( '' ) ;
}
$ ( 'alertAddBtn' ) . addEventListener ( 'click' , ( ) => {
const itemName = $ ( 'alertItemInput' ) . value . trim ( ) ;
if ( ! itemName ) return ;
alertConfigs . push ( { id : generateUUID ( ) , itemName , nameLower : itemName . toLowerCase ( ) } ) ;
saveAlertConfigs ( ) ;
$ ( 'alertItemInput' ) . value = '' ;
renderAlertConfigList ( ) ;
populateAlertItemSuggestions ( ) ;
} ) ;
$ ( 'alertItemInput' ) . addEventListener ( 'keydown' , e => { if ( e . key === 'Enter' ) $ ( 'alertAddBtn' ) . click ( ) ; } ) ;
$ ( 'alertConfigList' ) . addEventListener ( 'click' , e => {
const id = e . target . dataset . delAlert ;
if ( ! id ) return ;
alertConfigs = alertConfigs . filter ( c => c . id !== id ) ;
saveAlertConfigs ( ) ;
renderAlertConfigList ( ) ;
populateAlertItemSuggestions ( ) ;
} ) ;
updateTimerTileStatus ( ) ;
startTimerClock ( ) ;
async function initFarmTracker ( logPath , resetCounters ) {
if ( ! ft ) ft = createFarmTracker ( ) ;
await ft . start ( logPath , resetCounters ) ;
@@ -1875,6 +2515,35 @@
} ) ( ) ;
} ) ( ) ;
// ---------- 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 >