Kayak & Canoe
Automatic Protocols
System ●●●
00 : 00 : 00
⟳
⟳
⟳

АВТОРИЗАЦИЯ

🏁 ФИНИШ

Ожидание старта
0:00:00.00
offset: — ms  |  RTT: — ms  |  poll: 1500 ms
    или повторное выполнение скрипта) вешал второй обработчик на ту же кнопку и пробел — одно нажатие писалось дважды (BACKLOG п.13). */ if (window.kcfFinishTimerBound) return; window.kcfFinishTimerBound = true; var ajaxUrl = 'https://aps.kcf.md/wp-admin/admin-ajax.php', canManage = false, timerNonce = '8ecda21cdb', serverOffset = 0; /* ── стабильные часы (performance.now → монотонный) ── */ var perfStart = performance.now(), wallAtPerfStart = Date.now(); function nowStable(){ return wallAtPerfStart + (performance.now() - perfStart); } function nowServerBased(){ return nowStable() + serverOffset; } function syncServerTime(){ var t0 = nowStable(); $.post(ajaxUrl, {action:'kcf_timer_now'}, function(r){ var t2 = nowStable(), rtt = t2 - t0, oneWay = rtt / 2; serverOffset = (r.server - oneWay) - t2; }, 'json'); } /* ── состояние ── */ var startTimeServer = 0, running = false, placeCounter = 0, resultsCache = []; var $status = $('#st-status'), $timer = $('#st-timer'), $btnFin = $('#st-finish-btn'), $place = $('#st-place'), $btnEnd = $('#st-end-btn'), $btnReady = $('#st-ready-btn'), $res = $('#st-results'); /* ── debug ── */ var lastRtt = 0; function dbgUpdate(){ $('#dbg-offset').text(serverOffset.toFixed(1)); $('#dbg-rtt').text(lastRtt.toFixed(1)); $('#dbg-poll').text(running ? '2000' : '3000'); } /* ── формат H:MM:SS.cc ── */ function fmt(ms){ if(ms < 0) ms = 0; var h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000).toString().padStart(2,'0'), s = Math.floor((ms % 60000) / 1000).toString().padStart(2,'0'), cs = Math.floor((ms % 1000) / 10).toString().padStart(2,'0'); return h + ':' + m + ':' + s + '.' + cs; } function renderResults(list){ $res.html(list.map(function(r){ var t = fmt(r.finish_time); return '
  1. ' + '' + t + '' + finishPendingMark(r) + '' + '
  2. '; }).join('') + (finishUnsentNote() ? '
  3. ' + finishUnsentNote() + '
  4. ' : '')); } /* ── очередь нажатий «Финиш» (KCF_Timer::finish_queue_js) ── */ var finishQueue = [], finishSending = false, finishStuck = false; function queueFinish(row){ row.pending = true; finishQueue.push(row); sendFinish(); } function sendFinish(){ if(finishSending || !finishQueue.length) return; finishSending = true; var row = finishQueue[0]; $.post(ajaxUrl, {action:'kcf_timer_finish', security:timerNonce, place:row.place, time:Math.round(row.finish_time)}, null, 'json') .done(function(r){ finishSending = false; if(r && r.success){ finishQueue.shift(); row.pending = false; if(!finishQueue.length) finishStuck = false; renderResults(resultsCache); sendFinish(); } else { finishFailed(); } }) .fail(function(){ finishSending = false; finishFailed(); }); } function finishFailed(){ finishStuck = true; renderResults(resultsCache); setTimeout(sendFinish, 1500); } function finishUnsentText(){ return finishQueue.length ? 'Не отправлено финишей: ' + finishQueue.length + '. Не закрывайте страницу — отправка повторяется сама.' : ''; } function finishUnsentNote(){ return finishStuck ? finishUnsentText() : ''; } function finishPendingMark(r){ return r.pending && finishStuck ? ' не отправлено' : ''; } window.addEventListener('beforeunload', function(e){ if(finishQueue.length){ e.preventDefault(); e.returnValue = ''; } }); /* ── Copy ── */ $res.on('click', '.st-copy-btn', function(){ var t = $(this).data('time'); if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(t); } else { var tmp = $(''); $('body').append(tmp); tmp.val(t).select(); document.execCommand('copy'); tmp.remove(); } var btn = $(this); btn.text('Copied').css({color:'#fff',background:'#27ae60',borderColor:'#27ae60'}); setTimeout(function(){ btn.text('Copy').css({color:'#fff',background:'#27ae60',borderColor:'#27ae60'}); }, 1500); }); /* ── финиши текущей гонки с сервера: после F5 список и номер места не начинаются заново (BACKLOG п.14) ── */ var resultsLoaded = false; function loadResults(){ resultsLoaded = true; $.post(ajaxUrl, {action:'kcf_timer_results'}, function(list){ if(!Array.isArray(list)) return; // Неотправленные нажатия остаются в списке поверх записанных на сервере. resultsCache = list.map(function(r){ return {place: +r.place, finish_time: +r.finish_time}; }).concat(finishQueue); placeCounter = resultsCache.reduce(function(m, r){ return Math.max(m, r.place); }, 0); renderResults(resultsCache); $place.text(placeCounter + 1); }, 'json'); } /* ── UI сброс ── */ function resetUI(){ startTimeServer = 0; running = false; resultsLoaded = false; placeCounter = 0; resultsCache = finishQueue.slice(); // неотправленные нажатия не прячем renderResults(resultsCache); $place.text('1'); $timer.text('0:00:00.00'); $status.text('Ожидание старта').css({background:'#fff3cd',color:'#856404'}); $btnFin.prop('disabled', true); $btnEnd.hide(); $btnReady.hide(); } /* ── тик таймера (requestAnimationFrame) ── */ function tick(){ if(running && startTimeServer){ var elapsed = nowServerBased() - startTimeServer; if(elapsed >= 0) $timer.text(fmt(elapsed)); } requestAnimationFrame(tick); } /* ── применение серверного статуса ── */ function applyState(r){ if(r.status === 'running'){ if(!running){ startTimeServer = r.startTime; running = true; loadResults(); } $status.text('Идёт гонка.').css({background:'#d4edda',color:'#155724'}); if(canManage){ $btnFin.prop('disabled', false); $btnEnd.show(); $btnReady.hide(); } } else if(r.status === 'ended'){ running = false; if(!resultsLoaded) loadResults(); $status.text('Гонка завершена. Внесите времена в протокол.').css({background:'#f8d7da',color:'#721c24'}); $btnFin.prop('disabled', true); if(canManage){ $btnEnd.hide(); $btnReady.show(); } } else { resetUI(); } } /* ── polling статуса (адаптивный: 300ms в гонке, 1500ms в ожидании) ── */ var checkStatusTimer = null; function scheduleCheck(interval){ clearTimeout(checkStatusTimer); checkStatusTimer = setTimeout(checkStatus, interval); } function checkStatus(){ var t0_cs = nowStable(); $.post(ajaxUrl, {action:'kcf_timer_status'}, function(r){ if(r.startTime && r.serverNow){ var t2 = nowStable(), rtt = t2 - t0_cs, oneWay = rtt / 2; serverOffset = (r.serverNow + oneWay) - t2; lastRtt = rtt; } applyState(r); dbgUpdate(); scheduleCheck(running ? 2000 : 3000); }, 'json').fail(function(){ scheduleCheck(3000); }); } /* ── кнопка ФИНИШ ── */ function doFinish(){ if(!running) return; var elapsed = nowServerBased() - startTimeServer; placeCounter++; var row = {place: placeCounter, finish_time: Math.round(elapsed)}; resultsCache.push(row); queueFinish(row); renderResults(resultsCache); $place.text(placeCounter + 1); if(navigator.vibrate) navigator.vibrate(120); } $btnFin.on('click', doFinish); /* ── Пробел = ФИНИШ ── */ $(document).on('keydown', function(e){ if(e.code === 'Space' && running && !$btnFin.prop('disabled')){ e.preventDefault(); doFinish(); } }); /* ── кнопка ЗАВЕРШИТЬ ГОНКУ ── */ $btnEnd.on('click', function(){ if(!confirm('Завершить гонку? Финишировало: ' + placeCounter)) return; $.post(ajaxUrl, {action:'kcf_timer_end', security: timerNonce}, function(){ checkStatus(); }); }); /* ── кнопка ФИНИШ ГОТОВ ── */ $btnReady.on('click', function(){ if(finishQueue.length){ alert(finishUnsentText()); return; } $.post(ajaxUrl, {action:'kcf_timer_ready', security: timerNonce}, function(){ resetUI(); }); }); /* ── запуск ── */ tick(); syncServerTime(); setInterval(syncServerTime, 10000); checkStatus(); });