feat(algebra-8): синхронизация прогресса учебника с каталогом

Раньше: алгебра 1 и 2 главы хранили прогресс только в localStorage,
поэтому каталог /textbooks показывал 0/N прочитано и кнопку 'Открыть'
даже после активной работы с учебником.

Теперь обе главы шлют POST /api/textbooks/:slug/progress:
- markLastPara(id) — при каждом goTo(); сервер запоминает last_para,
  каталог показывает кнопку 'Продолжить'.
- markParaRead(id) — когда STATE.progress[key] первый раз ≥ 50%
  (внутрипараграфный прогресс достаточен); сервер добавляет id в
  paragraphs_read[], каталог показывает '1/7 прочитано'.
- Дебаунс 600мс — несколько быстрых переходов схлопываются в один POST.
- keepalive:true + beforeunload-flush, чтобы последний переход не
  потерялся при закрытии вкладки.
- loadServerReadState() при init() — если на другом устройстве уже
  прочитаны параграфы, локальный STATE.progress поднимается до 100%
  для них (визуально совпадает с каталогом).

Slug: 'algebra-8' для ch1, 'algebra-8-ch2' для ch2.
This commit is contained in:
Maxim Dolgolyov
2026-05-27 16:01:26 +03:00
parent 64bd44088d
commit 66166f6294
2 changed files with 96 additions and 0 deletions
+48
View File
@@ -1069,6 +1069,52 @@ function bumpProgress(key, delta){
STATE.progress[key] = v;
saveProgress();
refreshProgressUI();
if(v >= 50) markParaRead(key);
}
/* Server sync of read/last_para — пишем в БД, чтобы каталог /textbooks показывал прогресс */
const _TB_SLUG = 'algebra-8';
const _markedRead = new Set();
let _pendingProgressBody = null, _progressTimer = null;
function _flushProgress(){
const body = _pendingProgressBody;
_pendingProgressBody = null;
if(!body) return;
const tok = (window.LS && LS.getToken) ? LS.getToken() : '';
if(!tok) return;
fetch('/api/textbooks/' + _TB_SLUG + '/progress', {
method:'POST',
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer ' + tok },
body: JSON.stringify(body),
keepalive: true,
}).catch(()=>{});
}
function _queueProgress(patch){
_pendingProgressBody = Object.assign(_pendingProgressBody || {}, patch);
if(_progressTimer) clearTimeout(_progressTimer);
_progressTimer = setTimeout(_flushProgress, 600);
}
function markLastPara(id){ _queueProgress({ last_para: id }); }
function markParaRead(id){
if(_markedRead.has(id)) return;
_markedRead.add(id);
_queueProgress({ mark_read: id });
}
window.addEventListener('beforeunload', _flushProgress);
function loadServerReadState(){
const tok = (window.LS && LS.getToken) ? LS.getToken() : '';
if(!tok) return;
fetch('/api/textbooks/' + _TB_SLUG, { headers:{ 'Authorization':'Bearer ' + tok } })
.then(r => r.ok ? r.json() : null)
.then(d => {
if(!d || !d.progress) return;
(d.progress.read || []).forEach(k => {
_markedRead.add(k);
if((STATE.progress[k] || 0) < 50) STATE.progress[k] = 100;
});
saveProgress(); refreshProgressUI();
})
.catch(()=>{});
}
function refreshProgressUI(){
const total = Object.values(STATE.progress).reduce((a,b)=>a+b,0) / 7;
@@ -1204,6 +1250,7 @@ function _goToFinish(id){
if(window.renderMathInElement){
setTimeout(()=>renderMathInElement(el, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false}),0);
}
markLastPara(id);
}
/* ════════════════════════════════════════════════════════
@@ -1460,6 +1507,7 @@ function init(){
buildParaSelector();
refreshProgressUI();
initMobileSidebar();
loadServerReadState();
goTo('p1'); // строит только §1, остальные — лениво при переходе
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
+48
View File
@@ -541,6 +541,52 @@ function bumpProgress(key, delta){
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
saveProgress();
refreshProgressUI();
if(STATE.progress[key] >= 50) markParaRead(key);
}
/* Server sync of read/last_para — пишем в БД, чтобы каталог /textbooks показывал прогресс */
const _TB_SLUG = 'algebra-8-ch2';
const _markedRead = new Set();
let _pendingProgressBody = null, _progressTimer = null;
function _flushProgress(){
const body = _pendingProgressBody;
_pendingProgressBody = null;
if(!body) return;
const tok = (window.LS && LS.getToken) ? LS.getToken() : '';
if(!tok) return;
fetch('/api/textbooks/' + _TB_SLUG + '/progress', {
method:'POST',
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer ' + tok },
body: JSON.stringify(body),
keepalive: true,
}).catch(()=>{});
}
function _queueProgress(patch){
_pendingProgressBody = Object.assign(_pendingProgressBody || {}, patch);
if(_progressTimer) clearTimeout(_progressTimer);
_progressTimer = setTimeout(_flushProgress, 600);
}
function markLastPara(id){ _queueProgress({ last_para: id }); }
function markParaRead(id){
if(_markedRead.has(id)) return;
_markedRead.add(id);
_queueProgress({ mark_read: id });
}
window.addEventListener('beforeunload', _flushProgress);
function loadServerReadState(){
const tok = (window.LS && LS.getToken) ? LS.getToken() : '';
if(!tok) return;
fetch('/api/textbooks/' + _TB_SLUG, { headers:{ 'Authorization':'Bearer ' + tok } })
.then(r => r.ok ? r.json() : null)
.then(d => {
if(!d || !d.progress) return;
(d.progress.read || []).forEach(k => {
_markedRead.add(k);
if((STATE.progress[k] || 0) < 50) STATE.progress[k] = 100;
});
saveProgress(); refreshProgressUI();
})
.catch(()=>{});
}
function addXp(n, src){
if(!n) return;
@@ -646,6 +692,7 @@ function goTo(id){
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
// glossary wrap — пройти по тексту секции и обернуть термины
setTimeout(()=>{ try { wrapGlossary(el); } catch(e){} }, 60);
markLastPara(id);
}
/* SIDEBAR */
@@ -1206,6 +1253,7 @@ function init(){
initSearch();
buildParaSelector();
refreshProgressUI();
loadServerReadState();
goTo('p7');
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс