From 66166f6294a94d9b54ac241832383b9a0ecf1120 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 27 May 2026 16:01:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(algebra-8):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=B1=D0=BD=D0=B8=D0=BA=D0=B0=20=D1=81=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше: алгебра 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. --- frontend/textbooks/algebra_8.html | 48 +++++++++++++++++++++++++++ frontend/textbooks/algebra_8_ch2.html | 48 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/frontend/textbooks/algebra_8.html b/frontend/textbooks/algebra_8.html index 9e69f88..3d7c9f9 100644 --- a/frontend/textbooks/algebra_8.html +++ b/frontend/textbooks/algebra_8.html @@ -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 выше — обновляем локальный прогресс diff --git a/frontend/textbooks/algebra_8_ch2.html b/frontend/textbooks/algebra_8_ch2.html index 84f5320..a6e3417 100644 --- a/frontend/textbooks/algebra_8_ch2.html +++ b/frontend/textbooks/algebra_8_ch2.html @@ -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 выше — обновляем локальный прогресс