From e2ff28a48258ac090fa8c40e2de194ef01a4b1d4 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 17:25:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(biochem):=20=D0=A4=D0=B0=D0=B7=D0=B0=204?= =?UTF-8?q?=20(=D1=81=D1=80=D0=B5=D0=B7)=20=E2=80=94=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=83=D1=82=D0=B5=D0=B9=20+=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learn-режим метаболических путей теперь сохраняет прохождение на пользователя (раньше прогресс терялся). - migration 044_bio_user_pathway: таблица bio_user_pathway(user_id, pathway, step, completed) с upsert. - biochemController: getPathwayProgress / savePathwayProgress; XP (+80) начисляется один раз при первом завершении пути (completed «липкий» через MAX), затем checkAchievements. Роуты GET/POST /biochem/pathways/progress. - js/api.js: biochemGetPathwayProgress / biochemSavePathwayProgress. - biochem-pathways.html: загрузка прогресса в init (галочка-SVG на пройденных путях), сохранение + тост «+XP» при завершении пути. Полный перенос данных путей в БД (4.1-4.3) отложен — хардкод путей работает, ценность миграции архитектурная; здесь доставлена пользовательская часть. Проверено: upsert, XP-once, completed-sticky на реальной БД. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/biochemController.js | 39 +++++++++++++++++++ .../db/migrations/044_bio_user_pathway.sql | 16 ++++++++ backend/src/routes/biochem.js | 2 + frontend/biochem-pathways.html | 37 ++++++++++++++++-- js/api.js | 4 ++ 5 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 backend/src/db/migrations/044_bio_user_pathway.sql diff --git a/backend/src/controllers/biochemController.js b/backend/src/controllers/biochemController.js index 0e927c3..f8bfb96 100644 --- a/backend/src/controllers/biochemController.js +++ b/backend/src/controllers/biochemController.js @@ -321,6 +321,44 @@ function deleteSaved(req, res) { res.json({ ok: true }); } +/* ── Прогресс прохождения путей (Learn-режим) ────────────────────────── */ +const PATHWAY_XP = 80; +const stmtsPath = { + getProg: db.prepare('SELECT pathway, step, completed FROM bio_user_pathway WHERE user_id=?'), + wasDone: db.prepare('SELECT completed FROM bio_user_pathway WHERE user_id=? AND pathway=?'), + upsert: db.prepare(`INSERT INTO bio_user_pathway (user_id, pathway, step, completed, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, pathway) DO UPDATE SET + step = excluded.step, + completed = MAX(completed, excluded.completed), + updated_at = datetime('now')`), +}; + +/* ── GET /api/biochem/pathways/progress ──────────────────────────────── */ +function getPathwayProgress(req, res) { + const out = {}; + for (const r of stmtsPath.getProg.all(req.user.id)) + out[r.pathway] = { step: r.step, completed: !!r.completed }; + res.json(out); +} + +/* ── POST /api/biochem/pathways/progress ─────────────────────────────── */ +function savePathwayProgress(req, res) { + const { pathway, step, completed } = req.body; + if (typeof pathway !== 'string' || !pathway) + return res.status(400).json({ error: 'pathway required' }); + const prev = stmtsPath.wasDone.get(req.user.id, pathway); + const firstCompletion = !!completed && !(prev && prev.completed); + stmtsPath.upsert.run(req.user.id, pathway, Math.max(0, parseInt(step) || 0), completed ? 1 : 0); + let xp = 0; + if (firstCompletion) { + xp = PATHWAY_XP; + awardXP(req.user.id, xp, `biochem_pathway:${pathway}`); + checkAchievements(req.user.id); + } + res.json({ ok: true, xp }); +} + /* ── util ────────────────────────────────────────────────────────────── */ function tryParse(v, fallback) { if (!v) return fallback; @@ -331,6 +369,7 @@ module.exports = { getElements, getMolecules, getMolecule, validate, getReactions, getChallenges, solveChallenge, getSaved, saveMolecule, deleteSaved, + getPathwayProgress, savePathwayProgress, // экспортируется для тестов структурной проверки structuralMatch, canonicalHash, }; diff --git a/backend/src/db/migrations/044_bio_user_pathway.sql b/backend/src/db/migrations/044_bio_user_pathway.sql new file mode 100644 index 0000000..4ea1e08 --- /dev/null +++ b/backend/src/db/migrations/044_bio_user_pathway.sql @@ -0,0 +1,16 @@ +-- 044_bio_user_pathway.sql +-- Прогресс прохождения метаболических путей (Learn-режим biochem-pathways). +-- Раньше прогресс не сохранялся; теперь шаг и факт завершения хранятся на +-- пользователя по ключу пути (glycolysis / krebs / oxidation / synthesis ...). +-- Награда (XP) начисляется один раз при первом завершении пути. + +CREATE TABLE IF NOT EXISTS bio_user_pathway ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pathway TEXT NOT NULL, + step INTEGER NOT NULL DEFAULT 0, + completed INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, pathway) +); + +CREATE INDEX IF NOT EXISTS idx_bio_user_pathway ON bio_user_pathway(user_id); diff --git a/backend/src/routes/biochem.js b/backend/src/routes/biochem.js index 3ca42eb..ea65f2f 100644 --- a/backend/src/routes/biochem.js +++ b/backend/src/routes/biochem.js @@ -14,5 +14,7 @@ router.post('/challenges/:id/solve', c.solveChallenge); router.get('/saved', c.getSaved); router.post('/saved', c.saveMolecule); router.delete('/saved/:id', c.deleteSaved); +router.get('/pathways/progress', c.getPathwayProgress); +router.post('/pathways/progress', c.savePathwayProgress); module.exports = router; diff --git a/frontend/biochem-pathways.html b/frontend/biochem-pathways.html index 56749c2..ac61b70 100644 --- a/frontend/biochem-pathways.html +++ b/frontend/biochem-pathways.html @@ -1060,6 +1060,35 @@ function clickNode(id) { // ═══════════════════════════════════════════════════════ // LEARN MODE // ═══════════════════════════════════════════════════════ +// ── Прогресс прохождения путей (персистентность Learn-режима) ── +let _pathProgress = {}; +async function loadPathProgress() { + try { _pathProgress = (await LS.biochemGetPathwayProgress()) || {}; } + catch { _pathProgress = {}; } + markCompletedChips(); +} +function markCompletedChips() { + document.querySelectorAll('.path-chip').forEach(chip => { + const key = chip.dataset.path; + const done = _pathProgress[key] && _pathProgress[key].completed; + let badge = chip.querySelector('.path-done'); + if (done && !badge) { + badge = document.createElement('span'); + badge.className = 'path-done'; + badge.style.cssText = 'display:inline-flex;margin-left:5px;color:#4ade80'; + badge.innerHTML = ''; + chip.appendChild(badge); + } else if (!done && badge) { badge.remove(); } + }); +} +function savePathCompletion() { + LS.biochemSavePathwayProgress(currentPath, learnStep, true).then(r => { + _pathProgress[currentPath] = { step: learnStep, completed: true }; + markCompletedChips(); + if (r && r.xp) LS.toast(`Путь пройден! +${r.xp} XP`, 'success'); + }).catch(() => {}); +} + function startLearn() { learnMode = true; learnStep = 0; @@ -1149,6 +1178,7 @@ function stepNav(dir) { function renderLearnComplete() { activeNode = null; + savePathCompletion(); // сохранить прохождение + начислить XP (один раз) renderNodes(PATHWAYS[currentPath]); document.getElementById('learn-active').innerHTML = `
@@ -1210,10 +1240,8 @@ svgArea.addEventListener('wheel', e => { async function init() { try { const user = await LS.getMe(); - const initials = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS'; - document.getElementById('nav-avatar').textContent = initials; - const nav2 = document.getElementById('nav-avatar2'); - if (nav2) nav2.textContent = initials; + LS.renderNavAvatar(document.getElementById('nav-avatar'), user); + LS.renderNavAvatar(document.getElementById('nav-avatar2'), user); if (user?.role === 'admin') document.getElementById('btn-admin').style.display = ''; LS.applyRoleSidebar(user); LS.showBoardIfAllowed(); @@ -1227,6 +1255,7 @@ async function init() { await new Promise(r => setTimeout(r, 60)); renderPath(); renderPathInfo(); + loadPathProgress(); // отметить пройденные пути галочкой if (window.lucide) lucide.createIcons(); LS.notif?.init(); LS.hideDisabledFeatures?.(); diff --git a/js/api.js b/js/api.js index 531eb94..003d95f 100644 --- a/js/api.js +++ b/js/api.js @@ -943,6 +943,8 @@ async function biochemSolveChallenge(id,payload) { return req('POST',`/bioche async function biochemGetSaved() { return req('GET', '/biochem/saved'); } async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); } async function biochemDeleteSaved(id) { return req('DELETE',`/biochem/saved/${id}`); } +async function biochemGetPathwayProgress() { return req('GET', '/biochem/pathways/progress'); } +async function biochemSavePathwayProgress(pathway,step,completed){ return req('POST','/biochem/pathways/progress',{pathway,step,completed}); } /* ── LS.prefs — server-synced user preferences ────────────────────────── Keys use dot-notation: 'wb.color', 'dashboard.hidden', etc. @@ -1056,6 +1058,7 @@ window.LS = { patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }), applyCosmetics: applyCosmetics, refreshNavAvatar, + renderNavAvatar, isGamificationEnabled, loadFeatures, clearFeaturesCache, @@ -1064,6 +1067,7 @@ window.LS = { biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemGetReactions, biochemGetChallenges, biochemSolveChallenge, biochemGetSaved, biochemSave, biochemDeleteSaved, + biochemGetPathwayProgress, biochemSavePathwayProgress, prefs: lsPrefs, };