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:
@@ -1069,6 +1069,52 @@ function bumpProgress(key, delta){
|
|||||||
STATE.progress[key] = v;
|
STATE.progress[key] = v;
|
||||||
saveProgress();
|
saveProgress();
|
||||||
refreshProgressUI();
|
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(){
|
function refreshProgressUI(){
|
||||||
const total = Object.values(STATE.progress).reduce((a,b)=>a+b,0) / 7;
|
const total = Object.values(STATE.progress).reduce((a,b)=>a+b,0) / 7;
|
||||||
@@ -1204,6 +1250,7 @@ function _goToFinish(id){
|
|||||||
if(window.renderMathInElement){
|
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);
|
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();
|
buildParaSelector();
|
||||||
refreshProgressUI();
|
refreshProgressUI();
|
||||||
initMobileSidebar();
|
initMobileSidebar();
|
||||||
|
loadServerReadState();
|
||||||
goTo('p1'); // строит только §1, остальные — лениво при переходе
|
goTo('p1'); // строит только §1, остальные — лениво при переходе
|
||||||
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
|
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
|
||||||
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
||||||
|
|||||||
@@ -541,6 +541,52 @@ function bumpProgress(key, delta){
|
|||||||
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
|
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
|
||||||
saveProgress();
|
saveProgress();
|
||||||
refreshProgressUI();
|
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){
|
function addXp(n, src){
|
||||||
if(!n) return;
|
if(!n) return;
|
||||||
@@ -646,6 +692,7 @@ function goTo(id){
|
|||||||
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
||||||
// glossary wrap — пройти по тексту секции и обернуть термины
|
// glossary wrap — пройти по тексту секции и обернуть термины
|
||||||
setTimeout(()=>{ try { wrapGlossary(el); } catch(e){} }, 60);
|
setTimeout(()=>{ try { wrapGlossary(el); } catch(e){} }, 60);
|
||||||
|
markLastPara(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SIDEBAR */
|
/* SIDEBAR */
|
||||||
@@ -1206,6 +1253,7 @@ function init(){
|
|||||||
initSearch();
|
initSearch();
|
||||||
buildParaSelector();
|
buildParaSelector();
|
||||||
refreshProgressUI();
|
refreshProgressUI();
|
||||||
|
loadServerReadState();
|
||||||
goTo('p7');
|
goTo('p7');
|
||||||
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
|
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
|
||||||
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
||||||
|
|||||||
Reference in New Issue
Block a user