fix(tracker): mark_read больше не дропается из-за syncPending

Раньше: клик по .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 прочитано' начнёт расти при кликах по пилюлям.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 17:08:49 +03:00
parent dad34dc1d6
commit dacc0eb4ac
+28 -10
View File
@@ -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 } : {});
});
}