feat(exam-prep F10): план по дате экзамена — виджет на дашборде + модалка + GET/PUT/DELETE /plan
This commit is contained in:
@@ -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)
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 = `
|
||||
<div class="ep-stats" id="dh-top-stats">
|
||||
@@ -63,6 +64,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ep-card" id="dh-plan"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||||
|
||||
<div class="dh-row">
|
||||
<div class="ep-card dh-recent">
|
||||
<h3>Последние попытки</h3>
|
||||
@@ -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) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ─── Plan widget (F10) ──────────────────────────────────────── */
|
||||
|
||||
function renderPlanWidget(examKey, plan) {
|
||||
const el = document.getElementById('dh-plan');
|
||||
if (!el) return;
|
||||
|
||||
if (!plan) {
|
||||
el.innerHTML = `
|
||||
<h3>План подготовки</h3>
|
||||
<div class="ep-card-hint">Укажите дату экзамена — посчитаем, сколько задач нужно делать в день, чтобы успеть.</div>
|
||||
<div class="ep-cta-row">
|
||||
<button class="ep-btn ep-btn-primary" id="dh-plan-create">
|
||||
<i data-lucide="calendar-plus"></i> Создать план
|
||||
</button>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="dh-plan-head">
|
||||
<h3>План подготовки</h3>
|
||||
<button class="dh-plan-edit" id="dh-plan-edit" title="Изменить план">
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dh-plan-grid">
|
||||
<div class="dh-plan-stat">
|
||||
<div class="ep-stat-label">До экзамена</div>
|
||||
<div class="ep-stat-value ${daysClass}">${daysLeft < 0 ? '—' : daysLeft}</div>
|
||||
<div class="ep-stat-sub">${daysLabel} · ${examDateFmt}</div>
|
||||
</div>
|
||||
<div class="dh-plan-stat">
|
||||
<div class="ep-stat-label">Сегодня</div>
|
||||
<div class="ep-stat-value ${doneToday >= tgt ? 'ep-good' : ''}">${doneToday} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${tgt}</span></div>
|
||||
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pctToday}%"></div></div>
|
||||
<div class="ep-stat-sub">${doneToday >= tgt ? 'цель на сегодня выполнена!' : `осталось ${tgt - doneToday}`}</div>
|
||||
</div>
|
||||
<div class="dh-plan-stat">
|
||||
<div class="ep-stat-label">Осталось задач</div>
|
||||
<div class="ep-stat-value">${tasksLeft}</div>
|
||||
<div class="ep-stat-sub">${daysLeft > 0 ? `~${Math.ceil(tasksLeft / daysLeft)} в день, чтобы успеть` : 'дата прошла'}</div>
|
||||
</div>
|
||||
</div>
|
||||
${expired ? `<div class="dh-plan-expired">Дата экзамена в прошлом. Перенесите план или удалите его.</div>` : ''}
|
||||
<div class="ep-cta-row" style="margin-top:14px">
|
||||
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice">
|
||||
<i data-lucide="play"></i> Сделать ${Math.max(1, tgt - doneToday)} задач сейчас
|
||||
</a>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="dh-plan-form">
|
||||
<div class="dh-plan-field">
|
||||
<label>Дата экзамена</label>
|
||||
<input type="date" id="dh-plan-date" value="${examDate}" min="${today}" class="dh-plan-input" />
|
||||
</div>
|
||||
<div class="dh-plan-field">
|
||||
<label>Задач в день (5–50)</label>
|
||||
<input type="number" id="dh-plan-target" value="${dailyTarget}" min="5" max="50" class="dh-plan-input" />
|
||||
<div class="dh-plan-hint">Если оставить пусто или вне диапазона — посчитаем автоматом.</div>
|
||||
</div>
|
||||
<div class="dh-plan-field" id="dh-plan-derived"></div>
|
||||
</div>`;
|
||||
|
||||
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 = `
|
||||
<div class="dh-plan-derived-card">
|
||||
<span>До экзамена: <b>${left < 0 ? Math.abs(left) + ' дн. назад' : left + ' ' + pluralRu(left, ['день','дня','дней'])}</b></span>
|
||||
<span>При ${tgt} задач/день: <b>${left > 0 ? left * tgt : 0} задач максимум</b></span>
|
||||
</div>`;
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user