feat(biochem): Фаза 4 (срез) — персистентность прогресса путей + награда
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) <noreply@anthropic.com>
This commit is contained in:
@@ -321,6 +321,44 @@ function deleteSaved(req, res) {
|
|||||||
res.json({ ok: true });
|
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 ────────────────────────────────────────────────────────────── */
|
/* ── util ────────────────────────────────────────────────────────────── */
|
||||||
function tryParse(v, fallback) {
|
function tryParse(v, fallback) {
|
||||||
if (!v) return fallback;
|
if (!v) return fallback;
|
||||||
@@ -331,6 +369,7 @@ module.exports = {
|
|||||||
getElements, getMolecules, getMolecule, validate,
|
getElements, getMolecules, getMolecule, validate,
|
||||||
getReactions, getChallenges, solveChallenge,
|
getReactions, getChallenges, solveChallenge,
|
||||||
getSaved, saveMolecule, deleteSaved,
|
getSaved, saveMolecule, deleteSaved,
|
||||||
|
getPathwayProgress, savePathwayProgress,
|
||||||
// экспортируется для тестов структурной проверки
|
// экспортируется для тестов структурной проверки
|
||||||
structuralMatch, canonicalHash,
|
structuralMatch, canonicalHash,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -14,5 +14,7 @@ router.post('/challenges/:id/solve', c.solveChallenge);
|
|||||||
router.get('/saved', c.getSaved);
|
router.get('/saved', c.getSaved);
|
||||||
router.post('/saved', c.saveMolecule);
|
router.post('/saved', c.saveMolecule);
|
||||||
router.delete('/saved/:id', c.deleteSaved);
|
router.delete('/saved/:id', c.deleteSaved);
|
||||||
|
router.get('/pathways/progress', c.getPathwayProgress);
|
||||||
|
router.post('/pathways/progress', c.savePathwayProgress);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1060,6 +1060,35 @@ function clickNode(id) {
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// LEARN MODE
|
// 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 = '<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
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() {
|
function startLearn() {
|
||||||
learnMode = true;
|
learnMode = true;
|
||||||
learnStep = 0;
|
learnStep = 0;
|
||||||
@@ -1149,6 +1178,7 @@ function stepNav(dir) {
|
|||||||
|
|
||||||
function renderLearnComplete() {
|
function renderLearnComplete() {
|
||||||
activeNode = null;
|
activeNode = null;
|
||||||
|
savePathCompletion(); // сохранить прохождение + начислить XP (один раз)
|
||||||
renderNodes(PATHWAYS[currentPath]);
|
renderNodes(PATHWAYS[currentPath]);
|
||||||
document.getElementById('learn-active').innerHTML = `
|
document.getElementById('learn-active').innerHTML = `
|
||||||
<div class="learn-complete">
|
<div class="learn-complete">
|
||||||
@@ -1210,10 +1240,8 @@ svgArea.addEventListener('wheel', e => {
|
|||||||
async function init() {
|
async function init() {
|
||||||
try {
|
try {
|
||||||
const user = await LS.getMe();
|
const user = await LS.getMe();
|
||||||
const initials = (user?.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
|
||||||
document.getElementById('nav-avatar').textContent = initials;
|
LS.renderNavAvatar(document.getElementById('nav-avatar2'), user);
|
||||||
const nav2 = document.getElementById('nav-avatar2');
|
|
||||||
if (nav2) nav2.textContent = initials;
|
|
||||||
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
||||||
LS.applyRoleSidebar(user);
|
LS.applyRoleSidebar(user);
|
||||||
LS.showBoardIfAllowed();
|
LS.showBoardIfAllowed();
|
||||||
@@ -1227,6 +1255,7 @@ async function init() {
|
|||||||
await new Promise(r => setTimeout(r, 60));
|
await new Promise(r => setTimeout(r, 60));
|
||||||
renderPath();
|
renderPath();
|
||||||
renderPathInfo();
|
renderPathInfo();
|
||||||
|
loadPathProgress(); // отметить пройденные пути галочкой
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
LS.notif?.init();
|
LS.notif?.init();
|
||||||
LS.hideDisabledFeatures?.();
|
LS.hideDisabledFeatures?.();
|
||||||
|
|||||||
@@ -943,6 +943,8 @@ async function biochemSolveChallenge(id,payload) { return req('POST',`/bioche
|
|||||||
async function biochemGetSaved() { return req('GET', '/biochem/saved'); }
|
async function biochemGetSaved() { return req('GET', '/biochem/saved'); }
|
||||||
async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); }
|
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 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 ──────────────────────────
|
/* ── LS.prefs — server-synced user preferences ──────────────────────────
|
||||||
Keys use dot-notation: 'wb.color', 'dashboard.hidden', etc.
|
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) }),
|
patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
applyCosmetics: applyCosmetics,
|
applyCosmetics: applyCosmetics,
|
||||||
refreshNavAvatar,
|
refreshNavAvatar,
|
||||||
|
renderNavAvatar,
|
||||||
isGamificationEnabled,
|
isGamificationEnabled,
|
||||||
loadFeatures,
|
loadFeatures,
|
||||||
clearFeaturesCache,
|
clearFeaturesCache,
|
||||||
@@ -1064,6 +1067,7 @@ window.LS = {
|
|||||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
||||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||||
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
||||||
|
biochemGetPathwayProgress, biochemSavePathwayProgress,
|
||||||
prefs: lsPrefs,
|
prefs: lsPrefs,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user