From c590c32b41cb2639777bfc5e68b6dacdedcfe957 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 11:17:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(exam-prep=20F10):=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D1=82=D0=B5=20=D1=8D=D0=BA?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20=E2=80=94=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=B4=D0=B0=D1=88?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B4=D0=B5=20+=20=D0=BC=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20+=20GET/PUT/DELETE=20/plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/exam-prep.js | 132 ++++++++++++++++++++ frontend/css/exam-prep.css | 71 +++++++++++ frontend/js/exam-prep/api.js | 1 + frontend/js/exam-prep/dashboard.js | 185 ++++++++++++++++++++++++++++- 4 files changed, 388 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 68b55e3..65cc5a7 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -227,6 +227,34 @@ const SQL = { ORDER BY started_at DESC LIMIT ? `), + + /* ── Study plan (F10) ───────────────────────────────────────── */ + getPlan: db.prepare(` + SELECT exam_date, daily_target, weak_focus, created_at, updated_at + FROM exam_user_plan + WHERE user_id = ? AND exam_key = ? + `), + upsertPlan: db.prepare(` + INSERT INTO exam_user_plan + (user_id, exam_key, exam_date, daily_target, weak_focus, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, exam_key) DO UPDATE SET + exam_date = excluded.exam_date, + daily_target = excluded.daily_target, + weak_focus = excluded.weak_focus, + updated_at = excluded.updated_at + `), + deletePlan: db.prepare(`DELETE FROM exam_user_plan WHERE user_id = ? AND exam_key = ?`), + + /* Tasks solved today (distinct, mc+open+long); used by "today" progress. */ + tasksSolvedToday: db.prepare(` + SELECT COUNT(DISTINCT a.exam_task_id) AS solved + FROM exam_attempts a + JOIN exam_tasks t ON t.id = a.exam_task_id + WHERE a.user_id = ? AND t.exam_key = ? + AND a.is_correct = 1 + AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now') + `), }; /* ── GET /api/exam-prep/tracks ── @@ -505,6 +533,110 @@ function stripPreview(html) { return text.length > 100 ? text.slice(0, 100) + '…' : text; } +/* ────────────────────────────────────────────────────────────────── + Study plan (F10) — by exam date + ────────────────────────────────────────────────────────────────── */ + +function isIsoDate(s) { + return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s); +} + +function daysBetween(yyyymmdd) { + // Calendar days from today to yyyymmdd (UTC). Negative if in past. + const [y, m, d] = yyyymmdd.split('-').map(Number); + const target = Date.UTC(y, m - 1, d); + const today = new Date(); + const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); + return Math.round((target - todayStart) / 86400000); +} + +function buildPlanPayload(row, userId, examKey) { + if (!row) return { plan: null }; + + const counts = SQL.countTasks.get(examKey); + const progress = SQL.userProgress.get(userId, examKey); + const todayRow = SQL.tasksSolvedToday.get(userId, examKey); + + const daysLeft = daysBetween(row.exam_date); + const tasksLeft = Math.max(0, counts.total - progress.tasks_solved); + + // Auto daily target if user hasn't customized: ceil(left / days) + let dailyTarget = row.daily_target; + if (!dailyTarget && daysLeft > 0) { + dailyTarget = Math.max(5, Math.min(50, Math.ceil(tasksLeft / daysLeft))); + } + + return { + plan: { + exam_date: row.exam_date, + daily_target: dailyTarget, + weak_focus: !!row.weak_focus, + created_at: row.created_at, + updated_at: row.updated_at, + days_left: daysLeft, + tasks_left: tasksLeft, + today: { + solved: todayRow.solved, + target: dailyTarget, + }, + }, + }; +} + +/* ── GET /api/exam-prep/:examKey/plan ── + Returns { plan: null } if user has no plan for this track. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/plan', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + const row = SQL.getPlan.get(req.user.id, examKey); + res.json(buildPlanPayload(row, req.user.id, examKey)); +}); + +/* ── PUT /api/exam-prep/:examKey/plan ── + Body: { exam_date: 'YYYY-MM-DD', daily_target?: int 5-50, weak_focus?: 0|1 } + Upserts the plan. exam_date is required; the other fields are optional. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.put('/:examKey/plan', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + + const examDate = req.body?.exam_date; + if (!isIsoDate(examDate)) return res.status(400).json({ error: 'exam_date must be YYYY-MM-DD' }); + + let dailyTarget = req.body?.daily_target; + if (dailyTarget != null) { + dailyTarget = Number(dailyTarget); + if (!Number.isInteger(dailyTarget) || dailyTarget < 5 || dailyTarget > 50) { + return res.status(400).json({ error: 'daily_target must be integer 5-50' }); + } + } else { + dailyTarget = 10; // sensible default; client can override + } + + const weakFocus = req.body?.weak_focus ? 1 : 0; + + const existing = SQL.getPlan.get(req.user.id, examKey); + const now = Date.now(); + SQL.upsertPlan.run( + req.user.id, examKey, examDate, dailyTarget, weakFocus, + existing ? existing.created_at : now, now + ); + + const row = SQL.getPlan.get(req.user.id, examKey); + res.json(buildPlanPayload(row, req.user.id, examKey)); +}); + +/* ── DELETE /api/exam-prep/:examKey/plan ── + Remove the user's plan for this track. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.delete('/:examKey/plan', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + SQL.deletePlan.run(req.user.id, examKey); + res.json({ ok: true }); +}); + /* ────────────────────────────────────────────────────────────────── Mock exam (F9) ────────────────────────────────────────────────────────────────── */ diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index f6eec77..833b8e7 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -830,6 +830,77 @@ font-size: .72rem; color: var(--text-3); } +/* ═══════════════════════════════════════════════════════════════ + Plan widget (`dh-plan-*`) — used by dashboard (F10) + ═══════════════════════════════════════════════════════════════ */ + +.dh-plan-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 14px; +} +.dh-plan-head h3 { margin-bottom: 0; } +.dh-plan-edit { + width: 32px; height: 32px; + border: 1.5px solid var(--border-h); + border-radius: 8px; + background: var(--surface); + color: var(--text-2); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all .15s; +} +.dh-plan-edit:hover { border-color: var(--violet); color: var(--violet); } +.dh-plan-edit svg { width: 14px; height: 14px; stroke-width: 2; } + +.dh-plan-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} +.dh-plan-stat { + background: rgba(155,93,229,.05); + border-radius: 10px; + padding: 14px 16px; +} + +.dh-plan-expired { + margin-top: 12px; padding: 10px 14px; + background: rgba(248,150,30,.10); color: #B45309; + border-left: 3px solid #F8961E; + border-radius: 6px; + font-size: .82rem; +} + +/* Modal form */ +.dh-plan-form { + display: flex; flex-direction: column; gap: 14px; +} +.dh-plan-field label { + display: block; font-size: .78rem; font-weight: 700; color: var(--text-2); + text-transform: uppercase; letter-spacing: .04em; margin-bottom: 6px; +} +.dh-plan-input { + width: 100%; + padding: 9px 12px; + border: 1.5px solid var(--border-h); + border-radius: 9px; + background: var(--surface); color: var(--text); + font-family: 'Manrope', sans-serif; font-size: .92rem; +} +.dh-plan-input:focus { outline: none; border-color: var(--violet); } +.dh-plan-hint { font-size: .72rem; color: var(--text-3); margin-top: 4px; } + +.dh-plan-derived-card { + display: flex; flex-direction: column; gap: 5px; + padding: 10px 12px; + background: rgba(6,214,160,.06); + border-left: 3px solid #06D6A0; + border-radius: 6px; + font-size: .85rem; + color: var(--text-2); +} +.dh-plan-derived-card b { color: var(--text); } + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/js/exam-prep/api.js b/frontend/js/exam-prep/api.js index 126e4a6..8111e88 100644 --- a/frontend/js/exam-prep/api.js +++ b/frontend/js/exam-prep/api.js @@ -22,6 +22,7 @@ getDashboard: (examKey) => LS.api(`${base(examKey)}/dashboard`), getPlan: (examKey) => LS.api(`${base(examKey)}/plan`), savePlan: (examKey, body) => LS.api(`${base(examKey)}/plan`, { method: 'PUT', body }), + deletePlan: (examKey) => LS.api(`${base(examKey)}/plan`, { method: 'DELETE' }), saveAttempt: (body) => LS.api(`/api/exam-prep/attempts`, { method: 'POST', body }), startMock: (examKey, body) => LS.api(`${base(examKey)}/mock/start`, { method: 'POST', body }), mockAnswer: (mockId, body) => LS.api(`/api/exam-prep/mock/${mockId}/answer`, { method: 'POST', body }), diff --git a/frontend/js/exam-prep/dashboard.js b/frontend/js/exam-prep/dashboard.js index 1c1e3a3..c234578 100644 --- a/frontend/js/exam-prep/dashboard.js +++ b/frontend/js/exam-prep/dashboard.js @@ -27,8 +27,9 @@ ? Math.round((progress.correct_attempts / progress.total_attempts) * 100) : null; - // Fetch F4 live aggregates in parallel with rendering shell. + // Fetch F4 live aggregates + F10 plan in parallel with rendering shell. const dashPromise = EP.api.getDashboard(track.exam_key).catch(() => null); + const planPromise = EP.api.getPlan(track.exam_key).catch(() => null); main.innerHTML = `
@@ -63,6 +64,8 @@
+
 
+

Последние попытки

@@ -119,6 +122,11 @@ renderMocks(dash.recent_mocks, track.exam_key); if (window.lucide) lucide.createIcons(); } + + // F10: plan widget + const planPayload = await planPromise; + renderPlanWidget(track.exam_key, planPayload?.plan || null); + if (window.lucide) lucide.createIcons(); })(); /* ════════════════════════════════════════════════════════════════ @@ -214,6 +222,181 @@ function renderHeatmap(items) {
`; } +/* ─── Plan widget (F10) ──────────────────────────────────────── */ + +function renderPlanWidget(examKey, plan) { + const el = document.getElementById('dh-plan'); + if (!el) return; + + if (!plan) { + el.innerHTML = ` +

План подготовки

+
Укажите дату экзамена — посчитаем, сколько задач нужно делать в день, чтобы успеть.
+
+ +
`; + el.querySelector('#dh-plan-create').onclick = () => openPlanModal(examKey, null); + return; + } + + const daysLeft = plan.days_left; + const tasksLeft = plan.tasks_left; + const tgt = plan.daily_target || 10; + const doneToday = plan.today.solved; + const pctToday = tgt ? Math.min(100, Math.round((doneToday / tgt) * 100)) : 0; + + const daysClass = daysLeft <= 7 ? 'ep-warn' : daysLeft <= 30 ? 'ep-violet' : ''; + const daysLabel = daysLeft < 0 + ? `${Math.abs(daysLeft)} ${pluralRu(Math.abs(daysLeft), ['день','дня','дней'])} назад` + : daysLeft === 0 + ? 'сегодня экзамен' + : `${daysLeft} ${pluralRu(daysLeft, ['день','дня','дней'])}`; + const examDateFmt = formatRuDate(plan.exam_date); + const expired = daysLeft < 0; + + el.innerHTML = ` +
+

План подготовки

+ +
+
+
+
До экзамена
+
${daysLeft < 0 ? '—' : daysLeft}
+
${daysLabel} · ${examDateFmt}
+
+
+
Сегодня
+
${doneToday} / ${tgt}
+
+
${doneToday >= tgt ? 'цель на сегодня выполнена!' : `осталось ${tgt - doneToday}`}
+
+
+
Осталось задач
+
${tasksLeft}
+
${daysLeft > 0 ? `~${Math.ceil(tasksLeft / daysLeft)} в день, чтобы успеть` : 'дата прошла'}
+
+
+ ${expired ? `
Дата экзамена в прошлом. Перенесите план или удалите его.
` : ''} + `; + el.querySelector('#dh-plan-edit').onclick = () => openPlanModal(examKey, plan); +} + +function openPlanModal(examKey, plan) { + const today = toIsoDate(new Date()); + const examDate = plan?.exam_date || addDaysIso(today, 60); // default: 2 months out + const dailyTarget = plan?.daily_target || 10; + + const body = ` +
+
+ + +
+
+ + +
Если оставить пусто или вне диапазона — посчитаем автоматом.
+
+
+
`; + + const actions = [ + { label: 'Отмена', onClick: () => m.close() }, + ]; + if (plan) { + actions.push({ + label: 'Удалить план', + onClick: async () => { + if (!confirm('Удалить план? Прогресс сохранится.')) return; + try { + await EP.api.deletePlan(examKey); + m.close(); + location.reload(); + } catch (e) { + m.setError(`Не удалось удалить: ${e.message || e}`); + } + }, + }); + } + actions.push({ + label: plan ? 'Сохранить' : 'Создать план', + primary: true, + onClick: async () => { + const date = m.body.querySelector('#dh-plan-date').value; + const target = Number(m.body.querySelector('#dh-plan-target').value) || 10; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + m.setError('Введите дату экзамена'); + return; + } + try { + await EP.api.savePlan(examKey, { + exam_date: date, + daily_target: Math.max(5, Math.min(50, target)), + }); + m.close(); + location.reload(); + } catch (e) { + m.setError(`Не удалось сохранить: ${e.message || e}`); + } + }, + }); + + const m = LS.modal({ + title: plan ? 'Изменить план' : 'Новый план подготовки', + content: body, + size: 'sm', + actions, + }); + + // Live-preview derived numbers + const dateInp = m.body.querySelector('#dh-plan-date'); + const tgtInp = m.body.querySelector('#dh-plan-target'); + const derived = m.body.querySelector('#dh-plan-derived'); + function updateDerived() { + const d = dateInp.value; + if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) { derived.innerHTML = ''; return; } + const left = daysFromTodayIso(d); + const tgt = Math.max(5, Math.min(50, Number(tgtInp.value) || 10)); + derived.innerHTML = ` +
+ До экзамена: ${left < 0 ? Math.abs(left) + ' дн. назад' : left + ' ' + pluralRu(left, ['день','дня','дней'])} + При ${tgt} задач/день: ${left > 0 ? left * tgt : 0} задач максимум +
`; + } + dateInp.oninput = updateDerived; + tgtInp.oninput = updateDerived; + updateDerived(); +} + +function addDaysIso(iso, n) { + const [y, m, d] = iso.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + n); + return toIsoDate(dt); +} +function daysFromTodayIso(iso) { + const [y, m, d] = iso.split('-').map(Number); + const t = Date.UTC(y, m - 1, d); + const n = new Date(); + const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate()); + return Math.round((t - today) / 86400000); +} +function formatRuDate(iso) { + if (!iso) return ''; + const [y, m, d] = iso.split('-').map(Number); + const months = ['янв','фев','мар','апр','мая','июн','июл','авг','сен','окт','ноя','дек']; + return `${d} ${months[m - 1]} ${y}`; +} + function renderMocks(items, examKey) { const el = document.getElementById('dh-mocks-list'); if (!el) return;