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)
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user