From dacc0eb4accd05edd0476767e8a79d3599feddb9 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 27 May 2026 17:08:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(tracker):=20mark=5Fread=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B4=D1=80=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7-=D0=B7?= =?UTF-8?q?=D0=B0=20syncPending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше: клик по .para-pill вызывал setLastPara() → POST с last_para → syncPending=true. Тут же вызывался markRead() → второй POST с mark_read → guard 'if (syncPending) return' молча отбрасывал его. Результат: каталог показывал 'Продолжить' (last_para пришёл), но '0 из N прочитано' (paragraphs_read остался пуст). Два уровня фикса: 1) wirePillTracking объединяет last_para + mark_read в ОДИН POST через коалесцирующий syncToServer(firstTime ? {mark_read:key} : {}) 2) syncToServer теперь не дропает патчи: если предыдущий POST в полёте, новые поля сохраняются в pendingExtra и отправляются после .finally() — гарантия 'ни один mark_read не теряется'. Затрагивает chemistry-9, physics-9, physics8_thermal/electro/optics — у них теперь '0/N прочитано' начнёт расти при кликах по пилюлям. --- frontend/js/textbook-tracker.js | 38 ++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/frontend/js/textbook-tracker.js b/frontend/js/textbook-tracker.js index e5e6ad9..7422b0b 100644 --- a/frontend/js/textbook-tracker.js +++ b/frontend/js/textbook-tracker.js @@ -24,11 +24,18 @@ })(); if (!Array.isArray(localState.read)) localState.read = []; - /* ── 1. Server sync (best-effort) ──────────────────────────────── */ + /* ── 1. Server sync (best-effort, с очередью) ───────────────────── + Если POST уже в полёте — следующий патч копится в pendingExtra + и отправляется после завершения. Так ни один mark_read не + теряется при быстрых кликах. */ let syncPending = false; + let pendingExtra = null; function syncToServer(extra) { if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return; - if (syncPending) return; + if (syncPending) { + pendingExtra = Object.assign(pendingExtra || {}, extra || {}); + return; + } syncPending = true; fetch('/api/textbooks/' + slug + '/progress', { method: 'POST', @@ -36,8 +43,14 @@ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + LS.getToken(), }, - body: JSON.stringify({ last_para: localState.last, ...extra }), - }).finally(() => { syncPending = false; }).catch(() => {}); + body: JSON.stringify({ last_para: localState.last, ...(extra || {}) }), + }).finally(() => { + syncPending = false; + if (pendingExtra) { + const next = pendingExtra; pendingExtra = null; + syncToServer(next); + } + }).catch(() => {}); } /* ── 2. Initial load: merge server data into local state ──────── */ @@ -191,18 +204,23 @@ localState.read.forEach(k => { refreshPillUI(k); refreshCheckUI(k); }); } - /* ── 7. Pill click → mark as last visited ─────────────────────── */ + /* ── 7. Pill click → last_para + (первый раз) mark_read ──────── + Объединяем оба обновления в один POST, чтобы syncPending-guard + в syncToServer не дропнул второй вызов в том же тике. */ function wirePillTracking() { document.body.addEventListener('click', e => { const pill = e.target.closest('.para-pill[data-para]'); if (!pill) return; const key = pill.dataset.para; - setLastPara(key); - // Auto-mark-as-read: первый клик по пилюле = открыл параграф = считается прочитанным - // (мягкая семантика — соответствует реальному поведению учеников) - if (!localState.read.includes(key)) { - markRead(key); + localState.last = key; + const firstTime = !localState.read.includes(key); + if (firstTime) { + localState.read.push(key); + refreshPillUI(key); + refreshCheckUI(key); } + persist(); + syncToServer(firstTime ? { mark_read: key } : {}); }); }